fix(code): make edit-block writes crash-safe via atomic rename (#1721)

applyEditBlock previously truncated the target file in-place before
writing the new bytes. A crash between truncate and writeAll could leave
the file empty, partially rewritten, or NUL-padded — high blast radius
since model-generated edits all flow through this path.

Switch to the standard atomic-replace pattern:
1. Resolve realpath so edits through a symlink modify the referent.
2. Write the new bytes to a sibling .tmp file with wx (exclusive create).
3. chmod to preserve the original mode under umask.
4. fsync the temp fd, close, then rename onto the target.
5. Best-effort fsync the parent directory.
6. On any error, unlink the temp file so we don't leak .tmp clutter.

Also fixes a latent short-write bug in the previous loop: `if (n <= 0)
break;` would silently truncate on a partial write. The new writeAllSync
throws instead.

One semantic change worth noting: atomic-rename breaks hardlinks. The
edited path now points at the new file while any other hardlink to the
old inode is left pointing at the pre-edit content. Industry-standard
tradeoff (same as the editor in your shell does), but a behavior change
versus the old in-place truncate.

New test covers the symlink-preservation case (edit through link →
link still exists → referent file has new content).

Thanks @YoungDan-hero for the careful patch.
This commit is contained in:
YoungDan-hero authored and GitHub committed 2026-05-24 20:20:20 -07:00
1 parent 7d49171a28
commit b40a5692ec
2 files changed
+108 -16

No files matched your search

+79 -15
View File
@@ -1,14 +1,17 @@
/** SEARCH must match byte-for-byte; empty SEARCH = create new file. No fuzzy match — silent wrong edit beats a missing one. */
import {
chmodSync,
closeSync,
existsSync,
fstatSync,
ftruncateSync,
fsyncSync,
mkdirSync,
openSync,
readFileSync,
readSync,
realpathSync,
renameSync,
unlinkSync,
writeFileSync,
writeSync,
@@ -91,6 +94,61 @@ function pathIsUnder(child: string, parent: string): boolean {
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
function writeAllSync(fd: number, buf: Buffer): void {
let written = 0;
while (written < buf.length) {
const n = writeSync(fd, buf, written, buf.length - written, written);
if (n <= 0) throw new Error("write returned 0 bytes before completing");
written += n;
}
}
function fsyncDirectoryBestEffort(path: string): void {
let fd: number | undefined;
try {
fd = openSync(path, "r");
fsyncSync(fd);
} catch {
/* directory fsync is best-effort across platforms */
} finally {
if (fd !== undefined) closeSync(fd);
}
}
function atomicReplaceFileSync(path: string, buf: Buffer, mode: number): void {
const tmp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
const permissions = mode & 0o7777;
let fd: number | undefined;
try {
fd = openSync(tmp, "wx", permissions);
writeAllSync(fd, buf);
try {
chmodSync(tmp, permissions);
} catch {
/* preserve mode when the platform allows it */
}
fsyncSync(fd);
closeSync(fd);
fd = undefined;
renameSync(tmp, path);
fsyncDirectoryBestEffort(dirname(path));
} catch (err) {
if (fd !== undefined) {
try {
closeSync(fd);
} catch {
/* fd may already be closed after a prior failure */
}
}
try {
unlinkSync(tmp);
} catch {
/* tmp may not exist */
}
throw err;
}
}
export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
const absRoot = resolve(rootDir);
const absTarget = resolveEditPath(rootDir, block.path);
@@ -135,9 +193,23 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
try {
// Modify path. ENOENT is reported as `file-missing` so the model
// knows it needs an empty SEARCH to create the file.
let fd: number;
let writeTarget: string;
try {
fd = openSync(absTarget, "r+");
writeTarget = realpathSync(absTarget);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {
path: block.path,
status: "file-missing",
message: "file does not exist; to create it, use an empty SEARCH block",
};
}
throw err;
}
let fd: number | undefined;
try {
fd = openSync(writeTarget, "r+");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {
@@ -182,20 +254,12 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
// a footgun when the same string legitimately appears in several
// unrelated places.
const replaced = `${content.slice(0, idx)}${adaptedReplace}${content.slice(idx + adaptedSearch.length)}`;
// Truncate first so a shorter result doesn't leave stale tail
// bytes; ftruncate also pads with NUL when the new length is
// longer, which we then overwrite below.
const outBuf = encodeFile(replaced, encoding);
ftruncateSync(fd, outBuf.length);
let written = 0;
while (written < outBuf.length) {
const n = writeSync(fd, outBuf, written, outBuf.length - written, written);
if (n <= 0) break;
written += n;
}
closeSync(fd);
fd = undefined;
atomicReplaceFileSync(writeTarget, encodeFile(replaced, encoding), stat.mode);
return { path: block.path, status: "applied" };
} finally {
closeSync(fd);
if (fd !== undefined) closeSync(fd);
}
} catch (err) {
return { path: block.path, status: "error", message: (err as Error).message };
+29 -1
View File
@@ -1,6 +1,14 @@
/** SEARCH/REPLACE parsing + application — fresh temp dir per test. */
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
lstatSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import iconv from "iconv-lite";
@@ -222,6 +230,26 @@ describe("applyEditBlock", () => {
expect(result.status).toBe("applied");
expect(readFileSync(join(root, "lf.txt"), "utf8")).toBe("LINE1\nLINE2\n");
});
it("preserves symlink targets instead of replacing the link with a regular file", () => {
const outside = resolve(root, "..", "outside-symlink-target.txt");
const link = join(root, "linked.txt");
writeFileSync(outside, "hello world\n", "utf8");
try {
symlinkSync(outside, link);
const result = applyEditBlock(
{ path: "linked.txt", search: "hello", replace: "goodbye", offset: 0 },
root,
);
expect(result.status).toBe("applied");
expect(lstatSync(link).isSymbolicLink()).toBe(true);
expect(readFileSync(outside, "utf8")).toBe("goodbye world\n");
} finally {
rmSync(link, { force: true });
rmSync(outside, { force: true });
}
});
});
describe("snapshotBeforeEdits + restoreSnapshots", () => {