Skip to content

fix(fs): atomic writes, real MultiEdit atomicity, bounded glob results - #17

Merged
ForkedInTime merged 2 commits into
mainfrom
fix/phase2-integrity
Aug 3, 2026
Merged

fix(fs): atomic writes, real MultiEdit atomicity, bounded glob results#17
ForkedInTime merged 2 commits into
mainfrom
fix/phase2-integrity

Conversation

@ForkedInTime

Copy link
Copy Markdown
Owner

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 continued 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::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/ got temp+rename in an earlier audit; the file tools never did.

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 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 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. 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 Err rather than a tool-level error result — a consistency wrinkle shared with other tools, noted but not changed here.

QA (triple-checked)

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.

ForkedInTime and others added 2 commits August 3, 2026 00:56
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>
@ForkedInTime
ForkedInTime merged commit 7b152d0 into main Aug 3, 2026
5 checks passed
@ForkedInTime
ForkedInTime deleted the fix/phase2-integrity branch August 3, 2026 08:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant