Skip to content

filesystem: write_file/edit_file destroy file creation time (birthtime) and file identity due to atomic-rename write strategy #4512

Description

@DannyAmmon

Summary

write_file and edit_file replace existing files via a temp-file + fs.rename pattern (writeFileContent and applyFileEdits in src/filesystem/lib.ts). There is a security rationale in the code comments: atomic rename prevents symlink race conditions (TOCTOU) between path validation and write. However, this strategy has an undocumented side effect: every write to an existing file creates a new inode, which

  • destroys the file's creation timestamp (birthtime on macOS/APFS and BSD, creation time on Windows/NTFS, crtime on ext4), and
  • breaks file identity: hard links are severed, and inode-based file watchers/editors tracking the file lose it.

Notably, the server itself treats the creation timestamp as first-class metadata: get_file_info returns it as a dedicated created: field. Reporting this field but silently destroying it on each of its own write operations is internally inconsistent.

To reproduce

  1. Create a file, note birthtime (e.g. stat -f "%SB" file.md on macOS, or via the server's own get_file_info).
  2. Call edit_file (or write_file) on it.
  3. Read birthtime again → it now equals the time of the edit.

(move_file correctly preserves birthtime, since rename keeps the inode.)

Root cause

writeFileContent falls back to temp-file + rename for existing files; applyFileEdits uses the same pattern unconditionally. rename replaces the target with a different file, so all creation metadata and identity of the original are lost.

Suggested fix

Preserve the symlink-safety property while writing in place:

  1. fs.open(filePath, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW) — fails with ELOOP if the path is a symlink, giving the same protection the rename pattern provides (no writes through symlinks swapped in after validation).
  2. fstat the handle and verify it is a regular file (defense in depth; also allows re-checking the resolved path if desired).
  3. ftruncate + write through the handle → same inode, birthtime, hard links, and watchers all preserved.

On POSIX platforms (macOS, Linux, BSD) O_NOFOLLOW is well supported. On Windows, symlink creation requires elevated privileges by default and the threat model differs; the current rename path could be retained there, or FILE_FLAG_OPEN_REPARSE_POINT semantics used.

Trade-off: In-place writes lose crash-atomicity (a crash mid-write can leave a partially written file, which the rename pattern avoids). If that property is considered essential, an alternative would be an opt-in mode (env var or tool argument, e.g. preserveFileIdentity) selecting the in-place strategy — or at minimum, documenting the metadata-destroying behavior of the current implementation in the tool descriptions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions