fix(fs): atomic writes, real MultiEdit atomicity, bounded glob results - #17
Merged
Conversation
Completes Phase 2. The security half shipped in #16; this is the data-integrity half — the four items that PR deliberately left open. ## MultiEdit was not atomic, despite saying it was Its doc comment promised edits were applied "atomically", but it wrote inside the loop — one `fs::write` per edit — and `continue`d past failures. A batch where edit 3 of 5 failed therefore left edits 1, 2, 4 and 5 on disk: a half-finished refactor, on disk, reported as a single "✗" among several "✓". For the usual case (rename a symbol in five places) that is a broken file the model may not notice is broken. Edits are now staged in memory and committed per file, all-or-nothing. Granularity is per file rather than per batch, so a failure in file A does not discard good edits to file B. Note the subtlety this exposed: edits used to compose only because each write landed immediately and the next edit re-read the file — the very thing that made it non-atomic. Staging had to carry that forward explicitly, so later edits read the staged content rather than the disk. Covered by a test. ## No atomic writes anywhere `fs::write` truncates before writing, so a crash, a full disk, or a kill between truncate and write left the user's file empty or partial — silently, with the original gone. `session/` was given temp+rename in an earlier audit; the file tools never were. `atomic_write` writes to a temp file in the *same directory* (so the rename is same-filesystem and therefore atomic — `/tmp` may be a different mount, where rename degrades to copy-then-delete), fsyncs before renaming so a crash cannot produce a present-but-empty file, and removes the temp file on any failure. **It carries the original file's mode across.** Renaming replaces the inode, so without that an edit to a `0600` file would silently republish it at `0644` — turning a routine edit into a disclosure. Verified by test. ## glob results were unbounded A broad pattern over a large tree built an unbounded Vec and joined it into one enormous string sent to the model as a tool result — cost and memory scaling with the repository, with nothing downstream capping it. Now capped at 1000, applied *after* the mtime sort so the most recently modified matches survive, with the truncation stated rather than silent. ## Encoding: checked, no defect CRLF and BOM both round-trip byte-identically through an edit. Non-UTF-8 files are refused with the bytes left untouched — no corruption. Tracker item closed rather than "fixed"; there was nothing to fix. The refusal surfaces as a hard `Err` rather than a tool-level error result, which is a consistency wrinkle shared with other tools, not a data-integrity problem — noted, not changed here. QA (triple-checked): 601 tests, 0 failures. Clippy clean under the CI gate. Release 19.08 MB. Zero panics in production code across all six Phase 2 files plus tools/mod.rs. The security tests from #16 re-run green. Each fix verified by reverting it: writing inside the loop fails the atomicity test, dropping mode preservation fails the permissions test, removing the cap fails the bounds test. Co-Authored-By: Arch Linux <noreply@archlinux.org>
Windows CI. `FileEditTool` is used solely by the `#[cfg(unix)]` permissions test, so a module-level import is dead code on Windows and trips `-D warnings`. Third time a Windows-only lint has caught something the Linux run could not. The pattern is now explicit: an import used only by a cfg-gated test has to be gated with it. Co-Authored-By: Arch Linux <noreply@archlinux.org>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes Phase 2. The security half shipped in #16; this is the data-integrity half — the four items that PR deliberately left open.
MultiEdit was not atomic, despite saying it was
Its doc comment promised edits were applied "atomically", but it wrote inside the loop — one
fs::writeper edit — andcontinued past failures. A batch where edit 3 of 5 failed left edits 1, 2, 4 and 5 on disk: a half-finished refactor, reported as a single✗among several✓. For the usual case (rename a symbol in five places) that's a broken file the model may not notice is broken.Edits are now staged in memory and committed per file, all-or-nothing. Granularity is per file rather than per batch, so a failure in file A doesn't discard good edits to file B.
One subtlety this exposed: edits used to compose only because each write landed immediately and the next edit re-read the file — the very thing that made it non-atomic. Staging had to carry that forward explicitly, so later edits read staged content rather than disk. Covered by a test.
No atomic writes anywhere
fs::writetruncates before writing, so a crash, a full disk, or a kill between truncate and write left the user's file empty or partial — silently, with the original gone.session/got temp+rename in an earlier audit; the file tools never did.atomic_writewrites to a temp file in the same directory (so the rename is same-filesystem and therefore atomic —/tmpmay be a different mount, where rename degrades to copy-then-delete), fsyncs before renaming so a crash can't produce a present-but-empty file, and cleans up the temp file on any failure.It carries the original file's mode across. Renaming replaces the inode, so without that an edit to a
0600file would silently republish it at0644— turning a routine edit into a disclosure. Verified by test.glob results were unbounded
A broad pattern over a large tree built an unbounded Vec and joined it into one enormous string sent to the model as a tool result — cost and memory scaling with the repository. Now capped at 1000, applied after the mtime sort so the most recently modified matches survive, with truncation stated rather than silent.
Encoding: checked, no defect
CRLF and BOM both round-trip byte-identically through an edit. Non-UTF-8 files are refused with bytes untouched — no corruption. Tracker item closed rather than fixed; there was nothing to fix. The refusal surfaces as a hard
Errrather than a tool-level error result — a consistency wrinkle shared with other tools, noted but not changed here.QA (triple-checked)
tools/mod.rsEach fix verified by reverting it: writing inside the loop fails the atomicity test, dropping mode preservation fails the permissions test, removing the cap fails the bounds test.