From d5f8a79c75e74be66078ec8a9884a056ccc628bf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 20:34:31 +0800 Subject: [PATCH 01/42] split unit U1 of PR 1833 (issue 1375) --- .../__tests__/safeWriteText.spec.ts | 922 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 420 ++++++++ 2 files changed, 1342 insertions(+) create mode 100644 src/services/file-safety/__tests__/safeWriteText.spec.ts create mode 100644 src/services/file-safety/safeWriteText.ts diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts new file mode 100644 index 0000000000..e2f947c8bc --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -0,0 +1,922 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import { execFile } from "child_process" +import type { ChildProcess } from "child_process" +import * as path from "path" + +import { RollbackFailureError, safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" + +// Full mock for fs/promises — all methods are vi.fn() stubs +vi.mock("fs/promises", () => ({ + mkdir: vi.fn(), + access: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), + rmdir: vi.fn(), + realpath: vi.fn(), + lstat: vi.fn(), +})) + +// Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare +// class stub so tests can build minimal Stats stand-ins via its prototype. +vi.mock("fs", () => ({ + openSync: vi.fn(), + writeSync: vi.fn(), + closeSync: vi.fn(), + mkdirSync: vi.fn(), + fsyncSync: vi.fn(), + chmodSync: vi.fn(), + fchmodSync: vi.fn(), + statSync: vi.fn(), + Stats: class Stats {}, +})) + +// Mock child_process.execFile (callback-based — must invoke callback to resolve) +vi.mock("child_process", () => ({ + execFile: vi.fn((cmd, args, opts, cb) => { + if (typeof cb === "function") cb(null) + }), +})) + +// Minimal stand-in for the ChildProcess that callback-form execFile returns. +const fakeChild = { kill: () => true } as unknown as ChildProcess + +// Helper that mirrors safeWriteText's path resolution exactly +function _resolvedTarget(filePath: string): string { + return path.resolve(filePath) +} +function _dirPath(filePath: string): string { + return path.dirname(_resolvedTarget(filePath)) +} +// Minimal Stats stand-in: the SUT only reads `.mode` from it. +function _stats(mode: number): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats + Object.assign(s, { mode }) + return s +} + +// ── Test 1: staging file created then cleaned after success ──────────────── + +describe("safeWriteText", () => { + beforeEach(() => { + vi.resetAllMocks() + // After resetAllMocks, vi.fn() returns undefined — restore promise defaults. + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.rename).mockResolvedValue(undefined) + vi.mocked(fs.unlink).mockResolvedValue(undefined) + vi.mocked(fs.rmdir).mockResolvedValue(undefined) + // Existing-target default: a regular 0o644 file. + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + // Default sync-write behaviour: report that all requested bytes were + // written. The Buffer overload passes (fd, buffer, offset, length), + // so the fourth argument is the requested length. + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + typeof args[3] === "number" ? args[3] : 0, + ) + }) + + describe("staging and cleanup", () => { + it("creates a temp file in the staging dir, fsyncs it, renames to target, and cleans up on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) // fd=1 + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + + await safeWriteText(targetPath, "hello world", { platform: "linux" }) + + // staging dir was created with private permissions — use + // stringContaining to handle Windows path resolution + expect(fsSync.mkdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), { + recursive: true, + mode: 0o700, + }) + // a pre-existing staging dir is repaired to private permissions too + expect(fsSync.chmodSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), 0o700) + + // temp file was opened for writing with the existing target's mode + // (default 0o644 from the statSync default mock) + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + + // content was written as a buffer (partial-write loop, full write) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("hello world", "utf8"), 0, 11) + + // fsync (sync form) was called on the fd + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // file was closed + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + + // atomic rename happened — realpath mock returns targetPath, so that's the dest + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink of temp (it's now the committed file; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + + it("removes the now-empty staging directory after a successful self-staged commit", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "hello", { platform: "linux" }) + + // the staging subdir is removed best-effort after the commit rename + // (stringContaining: the SUT and the test helper resolve Windows + // drive-relative paths differently, as in the existing staging tests) + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + // the win32 DACL restore gate must stay closed on other platforms: + // no icacls save or restore is attempted + expect(execFile).not.toHaveBeenCalled() + }) + + it("still removes the staging directory when no options are supplied at all", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // options is undefined: the self-staged check and the optional-chained + // DACL runner lookup must not dereference it + await expect(safeWriteText(targetPath, "hello")).resolves.toBeUndefined() + + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + if (process.platform === "win32") { + // default platform is win32: the DACL save + restore still ran + // through the default icacls path (options?.execFileRunner must + // not throw when options is undefined) + expect(vi.mocked(execFile)).toHaveBeenCalledTimes(2) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + } + }) + + it("does not remove the staging directory when the caller supplies its own tempPath", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const callerTemp = "/tmp/test-dir/caller-staged.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp }) + + // the caller owns its temp file's directory; safeWriteText must not + // rmdir a directory it did not create + expect(fs.rmdir).not.toHaveBeenCalled() + }) + + it("a failed staging-dir removal never fails the committed write", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rmdir).mockRejectedValue(Object.assign(new Error("ENOTEMPTY"), { code: "ENOTEMPTY" })) + + await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).resolves.toBeUndefined() + + // the commit rename still happened and the rmdir error was swallowed + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + + it("gives each self-staged write its own staging directory so a concurrent write cannot remove it", async () => { + const targetA = "/tmp/test-dir/target-a.txt" + const targetB = "/tmp/test-dir/target-b.txt" + vi.mocked(fs.realpath).mockImplementation((p) => Promise.resolve(p as string)) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetA, "a", { platform: "linux" }) + await safeWriteText(targetB, "b", { platform: "linux" }) + + // Two self-staged writes in the same directory must not share one staging + // directory: the first write's best-effort rmdir would otherwise delete the + // directory the second write had created but not yet opened (ENOENT on openSync). + const created = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0])) + const staging = created.filter((p) => p.includes(".file-safety-staging_")) + expect(staging).toHaveLength(2) + expect(staging[0]).not.toBe(staging[1]) + // Uniqueness comes from the documented name shape + // /.file-safety-staging__: pinning the shape + // keeps the separator and the random suffix meaningful, not just the prefix. + for (const dir of staging) { + expect(dir).toMatch(/\.file-safety-staging_\d+_[a-z0-9]+$/) + } + const removed = vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0])) + expect(removed).toEqual([staging[0], staging[1]]) + }) + + it("removes its own staging directory when a self-staged write fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).rejects.toThrow("EACCES") + + // The failed write's temp file is unlinked, then the directory it + // created is removed — a failed write must not leave an empty + // .file-safety-staging directory behind. + // mkdirSync created this write's staging directory; the temp file lives + // inside it, so the unlink targets a path under that directory. + const staging = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0])) + expect(staging).toHaveLength(1) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(staging[0])) + expect(fs.rmdir).toHaveBeenCalledWith(staging[0]) + // The directory is only empty after its temp file is gone, so the + // unlink must happen before the rmdir. + expect(vi.mocked(fs.unlink).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fs.rmdir).mock.invocationCallOrder[0], + ) + }) + + it("does not remove a staging directory it did not create when a caller-staged write fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const callerTemp = "/tmp/test-dir/caller-staged.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect( + safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp }), + ).rejects.toThrow("EACCES") + + // The caller owns that directory: only the caller's temp file is cleaned, + // never a rmdir of a directory safeWriteText never created. + expect(fs.unlink).toHaveBeenCalledWith(callerTemp) + expect(fs.rmdir).not.toHaveBeenCalled() + }) + }) + + // ── Test 2: fsync ordering ─────────────────────────────────────────────── + + describe("fsync ordering", () => { + it("calls fsync on the fd before close, and rename after close", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Verify call order: openSync(temp) → writeSync → fsyncSync(temp) + // → closeSync(temp) → rename. On POSIX the parent directory is then + // opened and fsynced after the commit rename, so openSync/fsyncSync/ + // closeSync each have a second (directory) call. + expect(vi.mocked(fsSync.openSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.writeSync).mock.calls.length).toBe(1) + expect(vi.mocked(fsSync.fsyncSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.closeSync).mock.calls.length).toBe(2) + + // the temp file was fully closed before the commit rename + expect(vi.mocked(fsSync.closeSync).mock.calls[0][0]).toBe(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + // The title promises the order, so compare the invocations rather than + // only count them: a rename before closeSync, or a close before fsync, + // would not be a durable commit. + const fsyncOrder = vi.mocked(fsSync.fsyncSync).mock.invocationCallOrder[0] + const closeOrder = vi.mocked(fsSync.closeSync).mock.invocationCallOrder[0] + const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[0] + expect(fsyncOrder).toBeLessThan(closeOrder) + expect(closeOrder).toBeLessThan(renameOrder) + }) + }) + + // ── Test 3: simulated failure between write and rename leaves target intact ── + + describe("crash/torn-write safety", () => { + it("simulated failure between fsync and rename leaves the target byte-identical and no temp left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + + await expect(safeWriteText(targetPath, "new data", { platform: "linux" })).rejects.toThrow("ENOSPC") + + // rename was attempted (the failure point) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // temp file was cleaned up on failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + + // backup was NOT created (backup:false by default), so target is untouched + // The only rename call was temp→target, not a rollback rename + expect(fs.rename).toHaveBeenCalledTimes(1) + }) + + it("a post-commit backup cleanup failure is non-fatal: the target stays committed and no temp is left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The post-commit backup unlink (SUT step 6) fails — the write must + // still succeed; an orphaned backup is the documented acceptable + // outcome, so the failure is swallowed instead of rolling back. + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EPERM")) + + await safeWriteText(targetPath, "data", { backup: true, platform: "linux" }) + + // the commit rename (temp -> target) still happened + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + + // the failing cleanup was the post-commit backup unlink + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + + // no rollback rename: the committed target is not restored from the backup + expect(fs.rename).toHaveBeenCalledTimes(2) + + // the staging temp was already committed by the rename; nothing + // temp-shaped is unlinked afterwards + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + }) + + // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── + + describe("backup:true", () => { + it("renames target -> backup before commit, deletes backup on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "new data", { backup: true }) + + // target was accessed (exists check) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // first rename: target -> backup + expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + + // second rename: temp -> target (realpath mock returns targetPath) + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + + // backup was deleted on success + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + + it("rollback: on failure after rename target->backup, restores backup to target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // first rename (target->backup) succeeds, second fails + let callCount = 0 + vi.mocked(fs.rename).mockImplementation(async () => { + callCount++ + if (callCount === 1) return // target -> backup + if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails + return // the rollback rename succeeds + }) + + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("ENOSPC") + + // rollback rename is the 3rd call (after target->backup and temp->target failure) + expect(fs.rename).toHaveBeenNthCalledWith(3, expect.stringContaining("safeWriteText.bak_"), targetPath) + + // temp was cleaned up on failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("a failed rollback reports the partial state, not only the publish error", async () => { + // The content is still on disk, but only at the backup path. A caller that gets + // just the publish error has data it cannot find at the expected path. + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + let callCount = 0 + vi.mocked(fs.rename).mockImplementation(async () => { + callCount++ + if (callCount === 1) return // target -> backup + if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails + throw new Error("EACCES") // the rollback rename fails too + }) + + let failure: RollbackFailureError | undefined + await safeWriteText(targetPath, "new data", { backup: true }).catch((e: unknown) => { + if (e instanceof RollbackFailureError) { + failure = e + return + } + throw e + }) + + expect(failure).toBeInstanceOf(RollbackFailureError) + expect(failure?.publishError).toBeInstanceOf(Error) + expect((failure?.publishError as Error).message).toBe("ENOSPC") + expect((failure?.rollbackError as Error).message).toBe("EACCES") + expect(failure?.backupPath).toContain("safeWriteText.bak_") + // The backup is what the caller can still recover, so it must stay on disk. + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + + it("backup:true when target does not exist: no backup created, just commit", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // fs.access resolves for dirPath check, but rejects for target check (backup path) + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + }) + + await safeWriteText(targetPath, "new data", { backup: true, platform: "linux" }) + + // no backup rename (target didn't exist) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // only one rename: temp -> target + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink (no backup to delete; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 5: win32 DACL path ────────────────────────────────────────────── + + describe("win32 DACL", () => { + it.skipIf(process.platform !== "win32")( + "copies target DACL onto staging file via icacls before rename on Windows", + async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls dump + restore were called (execFile is callback-based mock) + expect(execFile).toHaveBeenCalledTimes(2) + }, + ) + + it("non-win32: DACL path is unreachable when platform is not win32", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // icacls was NOT called on non-win32 + expect(execFile).not.toHaveBeenCalled() + }) + + it("win32 DACL failure falls back to plain rename (never fails the write)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls dump fails — the callback-based mock must invoke cb with an error. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write succeeded despite icacls failure (fallback to plain rename) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + // one icacls attempt only: a failed DACL apply must not try to restore + expect(execFile).toHaveBeenCalledTimes(1) + }) + + it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls save fails — a real icacls may have written a partial dump + // before erroring, so the dump path must be cleaned up and must never + // be used for a restore. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls save error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write committed; only the save was attempted (no restore from a failed dump) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(execFile).toHaveBeenCalledTimes(1) + const saveArgs = vi.mocked(execFile).mock.calls[0]?.[1] + expect(saveArgs?.[1]).toBe("/save") + // the dump path (possibly partially created by icacls) was unlinked + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + + it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) + + // icacls was called twice (save + restore) + expect(execFile).toHaveBeenCalledTimes(2) + + // First call: save DACL from target before backup rename + const firstCall = vi.mocked(execFile).mock.calls[0] + expect(firstCall[0]).toBe("icacls") + expect(firstCall[1]).toEqual([targetPath, "/save", expect.stringContaining(".acl.tmp"), "/T"]) + + // Second call: restore DACL onto directory after commit rename + const secondCall = vi.mocked(execFile).mock.calls[1] + expect(secondCall[0]).toBe("icacls") + expect(secondCall[1]).toEqual([ + expect.stringContaining("/tmp/test-dir"), + "/restore", + expect.stringContaining(".acl.tmp"), + ]) + + // dump file was unlinked after restore + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + + it("win32 DACL: dump is unlinked even when restore fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // icacls save succeeds, restore fails + let callCount = 0 + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + callCount++ + if (typeof cb === "function") { + cb(callCount === 1 ? null : new Error("icacls restore error"), "", "") + } + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write succeeded despite restore failure (best-effort) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) + + // dump file was still unlinked in finally + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + + it("win32 DACL: when target does not exist, no save/restore/dump", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // fs.access rejects for targetPath (ENOENT), but resolves for dirPath + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + return undefined + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls was NOT called (target absent → skip DACL entirely) + expect(execFile).not.toHaveBeenCalled() + + // no dump file created or unlinked + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 6: pre-written temp path (tempPath option) ────────────────────── + + describe("pre-written temp path", () => { + it("uses the provided tempPath, fsyncs it, and renames to target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + const customTempPath = "/tmp/custom-temp.tmp" + + // platform:linux skips DACL entirely so this test focuses on tempPath only + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // openSync was called on the custom temp path (r+ mode for fsync) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + + // fsync was called + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // rename happened — realpath mock returns targetPath + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + + // no unlink of custom temp (caller's concern; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + + // a caller-supplied tempPath must not create the staging directory + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + }) + + it("applies the existing target's mode to a caller-supplied tempPath before publishing", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // the caller-staged temp is fchmod'd to the restrictive target mode so + // the atomic rename cannot widen a 0o600 target (CWE-732 regression) + expect(fsSync.fchmodSync).toHaveBeenCalledWith(2, 0o600) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("keeps the temp's default mode when the target does not exist yet (ENOENT)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // no existing target, so nothing to preserve and no fchmod on the temp + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("propagates a non-ENOENT stat failure rather than defaulting the mode (caller-staged)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw eacces + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + // A target that cannot be stat'd is not a fresh target: publishing with + // the default mode would widen a restrictive target through the rename. + await expect( + safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow("EACCES") + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("propagates a non-ENOENT stat failure rather than defaulting the mode (self-staged)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const eio = Object.assign(new Error("EIO: i/o error"), { code: "EIO" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw eio + }) + + // The mode is read before the temp is opened, so a real I/O failure stops + // the write before anything is staged. + await expect(safeWriteText(targetPath, "hello world", { platform: "linux" })).rejects.toThrow("EIO") + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o444)) + vi.mocked(fsSync.openSync).mockReturnValue(3) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // a 0o444 target must not make openSync(tempPath, "r+") fail: the mode + // is applied with fchmodSync on the already-open fd, after the open + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fsSync.fchmodSync).toHaveBeenCalledWith(3, 0o444) + const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0] + const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0] + expect(openIdx).toBeLessThan(fchmodIdx) + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("applies the existing target's exact mode to the self-staged temp (umask must not narrow it)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o664)) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // openSync's creation mode is narrowed by the process umask (0o664 -> 0o644 with + // the common 0o022), and the rename publishes the temp's mode onto the target, + // so the existing target's mode must be applied on the fd before the commit. + expect(fsSync.fchmodSync).toHaveBeenCalledWith(1, 0o664) + const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0] + const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0] + expect(openIdx).toBeLessThan(fchmodIdx) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("does not fchmod the self-staged temp for a fresh target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Nothing exists to preserve: the default creation mode is the intended one. + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + }) + }) + + // ── Test 7: symlink handling (Finding 4 regression test) ───────────────── + + describe("symlink handling", () => { + it("a write through a symlink commits onto the resolved referent, never the link path", async () => { + const linkPath = "/tmp/links/link.txt" + const referentPath = "/tmp/targets/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(referentPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(linkPath, "new-content", { platform: "linux" }) + + // The commit rename must target the realpath result (the referent), never the link itself — + // that is what guarantees a write through a symlink replaces the referent's content + // and preserves the link. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), referentPath) + expect(fs.rename).not.toHaveBeenCalledWith(expect.anything(), linkPath) + }) + + it("when realpath reports ENOENT (target absent), uses the given path as-is", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + // lstat reports the path itself as absent, so this is a new target and + // the fallback is allowed. + vi.mocked(fs.lstat).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // rename still happened with the fallback path (path.resolve on /tmp → C:\tmp) + const resolvedFallback = _resolvedTarget(targetPath) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), resolvedFallback) + }) + + it("propagates a dangling symlink instead of writing through the link path", async () => { + // realpath resolves the referent, so a link whose target is missing reports + // ENOENT. Falling back to the link path would replace the symlink with a + // regular file, so the error must propagate and nothing may be committed. + const linkPath = "/tmp/test-dir/dangling-link.txt" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const linkStats = Object.create(fsSync.Stats.prototype) as fsSync.Stats + linkStats.isSymbolicLink = () => true + vi.mocked(fs.lstat).mockResolvedValue(linkStats) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await expect(safeWriteText(linkPath, "data", { platform: "linux" })).rejects.toThrow("ENOENT") + + expect(fs.rename).not.toHaveBeenCalled() + }) + }) + + // ── Test 8: review fixes (permissions, partial writes, resolution, durability) ── + + describe("review fixes", () => { + it("preserves the target's restrictive mode and tolerates a failed staging-dir permission repair", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + // a pre-existing staging dir may fail its best-effort permission repair + vi.mocked(fsSync.chmodSync).mockImplementationOnce(() => { + throw new Error("EACCES") + }) + + await safeWriteText(targetPath, "secret", { platform: "linux" }) + + // the staging file inherits the target's 0o600 mode and the write commits + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o600) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("falls back to the 0o644 default when the target does not exist yet", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + + await safeWriteText(targetPath, "fresh", { platform: "linux" }) + + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + }) + + it("loops on short writes until the full content is durable before fsync", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const content = "0123456789" // 10 bytes + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const buffer = Buffer.from(content, "utf8") + // first write (offset 0) reports 4 bytes (short write); the loop continues + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + args[2] === 0 ? 4 : typeof args[3] === "number" ? args[3] : 0, + ) + + await safeWriteText(targetPath, content, { platform: "linux" }) + + // [0,10) reports 4 bytes, then [4,10) writes the remaining 6 + expect(fsSync.writeSync).toHaveBeenCalledTimes(2) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(1, 1, buffer, 0, 10) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(2, 1, buffer, 4, 6) + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("fsyncs the parent directory after the commit rename on POSIX", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // temp fd=1 then parent-dir fd=2 - distinct fds prove the ordering + vi.mocked(fsSync.openSync).mockReturnValueOnce(1).mockReturnValue(2) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // the directory fsync (fd 2) happens only after the file fsync (fd 1); + // the dir path assertion is path-agnostic (stringContaining) because + // path.dirname renders the same input differently on Windows + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("test-dir"), "r") + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(1, 1) + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(2, 2) + expect(fsSync.closeSync).toHaveBeenCalledWith(2) + }) + + it("treats a failed parent-directory fsync as best-effort", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync) + .mockReturnValueOnce(1) + .mockImplementationOnce(() => { + throw new Error("EBADF") + }) + + // the content rename already committed; a missing directory fsync is not fatal + await safeWriteText(targetPath, "data", { platform: "linux" }) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("propagates realpath errors (EACCES and code-less) instead of the fallback path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fs.realpath).mockRejectedValueOnce(eacces) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) + expect(fs.rename).not.toHaveBeenCalled() + + const plain = new Error("resolution failed") + vi.mocked(fs.realpath).mockRejectedValueOnce(plain) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(plain) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("backup:true propagates access errors (EACCES and code-less) instead of skipping the backup", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" }) + const plain = new Error("access failed") + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // each write accesses dirPath then target; only the target access rejects + const rejectTarget = (error: Error) => async (p: unknown) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw error + } + vi.mocked(fs.access) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(plain)) + .mockImplementationOnce(rejectTarget(plain)) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toEqual( + expect.objectContaining({ code: "EACCES" }), + ) + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( + "access failed", + ) + expect(fs.rename).not.toHaveBeenCalled() + }) + }) + describe("content bytes", () => { + const targetPath = "/tmp/enc-dir/target.txt" + + beforeEach(() => { + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + }) + + it("stages UTF-8 bytes for string content", async () => { + await safeWriteText(targetPath, "héllo", { platform: "linux" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("héllo", "utf8"), 0, 6) + }) + + it("publishes caller-supplied bytes unchanged instead of re-encoding them", async () => { + // The extension host encodes a document with VS Code's own codec, which + // covers the legacy code pages and BOMs Node cannot represent, and hands + // the result over: those bytes must reach the commit rename exactly as + // they were given. + const bytes = Buffer.from([0x00, 0x68, 0x00, 0x69]) + await safeWriteText(targetPath, bytes, { platform: "linux" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, bytes, 0, 4) + }) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts new file mode 100644 index 0000000000..b5f82a4f81 --- /dev/null +++ b/src/services/file-safety/safeWriteText.ts @@ -0,0 +1,420 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" +import { execFile } from "child_process" + +export interface SafeWriteTextOptions { + /** + * When true, preserve the old-file semantics: rename target -> backup first, + * after commit rename delete the backup; on failure roll the backup back to + * the target path. When false (default) the atomic rename simply replaces + * the target -- crash-safe window is zero. + */ + backup?: boolean + + /** + * Platform override for testing. When omitted the real process.platform + * value is used. Set to "win32" or "linux" / "darwin" from tests so that + * both branches are reachable without needing a real Windows runner. + */ + platform?: string + + /** + * Custom execFile runner for testing (e.g. vi.fn). When omitted the real + * child_process.execFile is used. + */ + execFileRunner?: typeof execFile + + /** + * Pre-written temp path to use for the commit phase. When provided, + * safeWriteText skips creating its own staging file and uses this path + * instead (it still fsyncs before rename). Useful when a caller has + * already written data to a temp file via a custom stream. + */ + tempPath?: string +} + +/** + * A publish that failed and whose rollback also failed: the content survives only + * at the backup path, not at the canonical target. The publish failure stays the + * cause, and the rollback failure plus the backup location travel with the error so + * the caller can tell what it is looking at. + */ +export class RollbackFailureError extends Error { + readonly publishError: unknown + readonly rollbackError: unknown + readonly backupPath: string + + constructor(publishError: unknown, rollbackError: unknown, backupPath: string) { + super( + "Publish failed and the backup could not be restored to its original path -- the content is preserved at the backup location reported on this error.", + { cause: publishError }, + ) + this.name = "RollbackFailureError" + this.publishError = publishError + this.rollbackError = rollbackError + this.backupPath = backupPath + } +} +// -- helpers --------------------------------------------------------------- + +/** Generate a unique temp file name in the given directory. */ +function _tempName(dir: string, prefix: string): string { + return path.join(dir, "." + prefix + "_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp") +} + +/** Create a private per-write staging sub-directory inside *dir*. The name is + * unique per write, so concurrent writes never collide on their temp names and + * never remove a staging directory another write is still using: with one shared + * name, one write's best-effort rmdir could delete the directory another write + * had just created but not yet opened, failing its openSync with ENOENT. */ +function _stagingDir(dir: string): string { + const sd = path.join(dir, ".file-safety-staging_" + Date.now() + "_" + Math.random().toString(36).substring(2)) + // mode:0o700 protects a freshly created staging dir; the best-effort chmod + // repairs a pre-existing one (mkdirSync with recursive:true never chmods an + // existing directory), so staged temp files are never group/world readable. + fsSync.mkdirSync(sd, { recursive: true, mode: 0o700 }) + try { + fsSync.chmodSync(sd, 0o700) + } catch { + // best-effort: chmod denied or unavailable; a fresh dir was still + // created with the requested mode + } + return sd +} + +function _fsyncFile(fd: number): void { + fsSync.fsyncSync(fd) +} + +/** Save the DACL of *srcPath* to a dump file on Windows. + * Returns true when the dump was written successfully; false otherwise. + * Never throws — callers treat failure as "skip DACL handling". */ +async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner("icacls", [srcPath, "/save", dumpPath, "/T"], { windowsHide: true }, (err) => + err ? reject(err) : resolve(), + ) + }) + return true + } catch { + return false + } +} + +/** Restore a DACL dump onto *dirPath* on Windows. + * Best-effort: content is already committed, so failure is non-fatal. */ +async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner("icacls", [dirPath, "/restore", dumpPath], { windowsHide: true }, (err) => + err ? reject(err) : resolve(), + ) + }) + } catch { + // best-effort; content already committed + } +} + +// -- public API ------------------------------------------------------------ + +/** + * Resolve the publish target: the symlink referent when the given path is an + * existing symlink, the path itself otherwise. Only ENOENT (target absent yet) + * may fall back to the given path; any other resolution error (EACCES, EIO, ...) + * propagates so a broken or unreadable symlink is never written through its + * link path. Callers that stage a temp file themselves must stage it beside + * the resolved path: the commit is a rename onto the referent, and a rename + * across filesystems fails with EXDEV. + */ +export async function resolvePublishTarget(absoluteFilePath: string): Promise { + return fs.realpath(absoluteFilePath).catch(async (error: unknown) => { + const code = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined + if (code !== "ENOENT") throw error + // ENOENT also covers a dangling symlink, which must never be written through. + const linkStat = await fs.lstat(absoluteFilePath).catch(() => undefined) + if (linkStat?.isSymbolicLink()) throw error + return absoluteFilePath + }) +} +/** + * Distinguish "the target does not exist" from a real I/O failure (EACCES, + * EIO, ...). The mode-preservation path may only fall back to the fresh-file + * default on ENOENT; any other failure is propagated, otherwise a restrictive + * target (0o600) would be published with the default 0o644 through the rename. + */ +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String((error as { code: unknown }).code) + : undefined +} + +/** + * Canonicalize the parent directory and re-join the basename. fs.realpath + * canonicalizes every component, including a symlinked ancestor directory or a + * Windows 8.3 short name, so a lock key must be canonical even when the file + * itself is not there yet -- otherwise the key for one file depends on whether + * the file exists when the key is computed, and two writers take two locks. + */ + +async function canonicalDirKey(absoluteFilePath: string): Promise { + const dirPath = path.dirname(absoluteFilePath) + const canonicalDir = await fs.realpath(dirPath).catch(() => dirPath) + return path.join(canonicalDir, path.basename(absoluteFilePath)) +} + +/** + * Lock key for a publish target: the symlink referent when the path is an + * existing symlink, the path itself otherwise. Unlike resolvePublishTarget this + * tolerates a dangling link, because the lock key has to be computable while a + * peer writer is mid-commit (backup mode renames the referent away and back). + * The walk is bounded so a two-link cycle terminates, and every key it returns is + * canonicalized through canonicalDirKey. + */ +export async function resolveLockKey(absoluteFilePath: string): Promise { + try { + return await canonicalDirKey(await resolvePublishTarget(absoluteFilePath)) + } catch { + // A real readlink throws for anything that is not a link, so a normal chain + // ends the walk. Two links that point at each other never would, so the + // walk is bounded and callers use the key they actually reached. + let key = absoluteFilePath + for (let depth = 0; depth < 8; depth++) { + const target = await fs.readlink(key).catch(() => undefined) + if (target === undefined) return await canonicalDirKey(key) + key = await canonicalDirKey(path.resolve(path.dirname(key), target)) + } + return await canonicalDirKey(key) + } +} + +export async function safeWriteText( + filePath: string, + content: string | Uint8Array, + options?: SafeWriteTextOptions, +): Promise { + const absoluteFilePath = path.resolve(filePath) + + // Resolve the symlink referent (see resolvePublishTarget). + const targetPath = await resolvePublishTarget(absoluteFilePath) + const dirPath = path.dirname(targetPath) + + // Ensure parent directory exists (mirrors safeWriteJson behaviour). + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + + // Create the staging directory only when we generate the temp file there; + // callers supplying their own tempPath (e.g. safeWriteJson) must not be left + // with an empty .file-safety-staging directory behind. Track the directory this + // write created so its cleanup removes its own directory, not a shared one. + let stagingDir: string | null = null + let tempPath: string + if (options?.tempPath) { + tempPath = options.tempPath + } else { + stagingDir = _stagingDir(dirPath) + tempPath = _tempName(stagingDir, "safeWriteText") + } + + let backupPath: string | null = null + let releaseBackupOnSuccess = false + // Non-null only when the win32 step-2 block saved a successful DACL dump: + // it gates the step-5 restore and is tracked for the cleanup unlinks. + let daclDumpPath: string | null = null + + try { + // -- Step 1: write content to staging temp file ------------------- + if (!options?.tempPath) { + // Preserve the existing target's permissions: the staging file must + // not be published wider than the file it replaces (a 0o600 target + // must not become 0o644 through the atomic rename). + // Encode before opening the staging file: an encoding Node cannot + // represent must not leave a half-written temp file behind. + // A string is encoded as UTF-8; bytes handed in by the caller (the + // extension host encodes a document with VS Code's own codec, which + // covers the legacy code pages Node cannot represent) are published + // unchanged. + const buffer = Buffer.from(content) + let targetMode = 0o644 // default for a fresh target + let targetExists = false + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + targetExists = true + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") throw error + // target does not exist yet - keep the default + } + // openSync's creation mode is narrowed by the process umask, so an + // existing 0o664 target would be published as 0o644 through the + // rename. Apply the existing target's exact mode on the fd, as the + // caller-staged branch does; a fresh target keeps the default mode. + const fd = fsSync.openSync(tempPath, "w", targetMode) + try { + if (targetExists) { + fsSync.fchmodSync(fd, targetMode) + } + // Loop until every byte is written: writeSync can report a short + // (partial) write, and publishing a truncated staging file would + // commit corrupt content. + let offset = 0 + while (offset < buffer.length) { + offset += fsSync.writeSync(fd, buffer, offset, buffer.length - offset) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } else { + // Preserve the existing target's mode (CWE-732): the caller-staged + // temp carries its own creation mode, and publishing it as-is would + // widen a restrictive target (e.g. 0o600 -> 0o644) through rename. + // The mode is applied with fchmodSync on the open fd (AFTER openSync): + // chmodSync on the path before the open would make a read-only target + // (0o400/0o444) fail openSync(tempPath, "r+") with EACCES. + let targetMode: number | null = null + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") throw error + // target does not exist yet - keep the temp's default mode + } + const fd = fsSync.openSync(tempPath, "r+") + try { + if (targetMode !== null) { + fsSync.fchmodSync(fd, targetMode) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } + + // -- Step 2 (win32): save DACL BEFORE backup rename --------------- + const platform = options?.platform ?? process.platform + if (platform === "win32") { + try { + await fs.access(targetPath) // target exists? + const dumpPath = targetPath + ".acl.tmp" + const saved = await _saveDaclWindows(targetPath, dumpPath, options?.execFileRunner) + if (saved) { + // Only a successfully saved dump may be restored onto the + // committed file (step 5). + daclDumpPath = dumpPath + } else { + // A failed icacls may have left a partial dump behind; + // remove it now (best-effort) so no partial dump survives and + // no later step can restore from it. + await fs.unlink(dumpPath).catch(() => {}) + } + } catch { + // target does not exist or access failed — no DACL handling + daclDumpPath = null + } + } + + try { + // -- Step 3 (backup:true): rename target -> backup -------------- + if (options?.backup) { + try { + await fs.access(targetPath) + backupPath = _tempName(dirPath, "safeWriteText.bak") + await fs.rename(targetPath, backupPath) + releaseBackupOnSuccess = true + } catch (err: unknown) { + const code = + typeof err === "object" && err !== null && "code" in err + ? (err as { code?: string }).code + : undefined + if (code !== "ENOENT") throw err + } + } + + // -- Step 4: atomic rename temp -> target --------------------- + await fs.rename(tempPath, targetPath) + + // -- Step 4b (POSIX): fsync the parent directory so the directory entry + // changed by the commit rename is durable, not just the file content. + if (platform !== "win32") { + try { + const dirFd = fsSync.openSync(dirPath, "r") + try { + _fsyncFile(dirFd) + } finally { + fsSync.closeSync(dirFd) + } + } catch { + // best-effort: the content rename already committed + } + } + + // -- Step 5 (win32): restore DACL AFTER commit rename --------- + // daclDumpPath is non-null only when the win32 step-2 block saved a + // successful dump, so this gate is closed on every other platform + // and on every failed save. + if (daclDumpPath !== null) { + const restoredDir = path.dirname(targetPath) + await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + } + + // -- Step 6 (backup:true): delete backup on success ----------- + if (releaseBackupOnSuccess && backupPath) { + try { + await fs.unlink(backupPath) + } catch { + // non-fatal — orphaned backup is acceptable + } + } + } finally { + // Unlink DACL dump regardless of success/failure in this span. + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + } + + // tempPath is now the committed file; no cleanup needed. + + // Best-effort: remove the now-empty staging directory. Self-staged + // writes only, and only this write's own directory: a per-write directory + // cannot be the one another concurrent write is still using. A failure must + // never un-commit a published file, so the removal swallows all errors. + if (stagingDir) { + await fs.rmdir(stagingDir).catch(() => {}) + } + } catch (originalError: unknown) { + if (backupPath && releaseBackupOnSuccess) { + try { + await fs.rename(backupPath, targetPath) + } catch (rollbackError: unknown) { + // The content survives only at the backup path now, and the canonical + // target is gone. Reporting just the publish failure would leave the + // caller with data it cannot find at the expected path, so the + // partial-failure state travels with the error. + throw new RollbackFailureError(originalError, rollbackError, backupPath) + } + } + try { + await fs.unlink(tempPath).catch(() => {}) + } catch { + // cleanup failure is non-fatal + } + + // A failed self-staged write must not leave its staging directory behind. + // Only the directory this write created, and only after its temp file is + // gone, so the directory is empty and the removal stays best-effort. + if (stagingDir) { + await fs.rmdir(stagingDir).catch(() => {}) + } + + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + + throw originalError + } +} From aa0cdaba0a6741daab9f3c06cf1eea9ad16f481a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 20:49:29 +0800 Subject: [PATCH 02/42] fix(file-safety): close the pre-merge findings on the publish primitive (U1, issue 1375) Split unit U1 of PR 1833. Three changes, each with a test that fails without it: - a caller-supplied staging path is checked for location and file type before anything is written, so an arbitrary path or a symlink cannot be published onto the target; - a failed parent-directory fsync on POSIX is reported as PostCommitDurabilityError instead of being swallowed, so a successful return never claims durability the filesystem did not grant; - the staged file and this write's own staging directory are released before RollbackFailureError is thrown. Focused coverage for resolveLockKey added: canonical parent directory, a dangling-link chain, and termination at the bounded depth on a two-link cycle. --- .../__tests__/safeWriteText.spec.ts | 169 ++++++++++++++++-- src/services/file-safety/safeWriteText.ts | 82 ++++++++- 2 files changed, 229 insertions(+), 22 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index e2f947c8bc..66878d8b9d 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -4,7 +4,14 @@ import { execFile } from "child_process" import type { ChildProcess } from "child_process" import * as path from "path" -import { RollbackFailureError, safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" +import { + PostCommitDurabilityError, + resolveLockKey, + RollbackFailureError, + safeWriteText, + StagingPathError, + type SafeWriteTextOptions, +} from "../safeWriteText" // Full mock for fs/promises — all methods are vi.fn() stubs vi.mock("fs/promises", () => ({ @@ -15,6 +22,7 @@ vi.mock("fs/promises", () => ({ rmdir: vi.fn(), realpath: vi.fn(), lstat: vi.fn(), + readlink: vi.fn(), })) // Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare @@ -49,6 +57,25 @@ function _dirPath(filePath: string): string { return path.dirname(_resolvedTarget(filePath)) } // Minimal Stats stand-in: the SUT only reads `.mode` from it. +// Async lstat stand-in: the SUT only asks whether the path is a link or a file. +function _fileStats(isLink: boolean) { + return { isSymbolicLink: () => isLink, isFile: () => !isLink } +} + +function mockDefaults(): void { + vi.resetAllMocks() + // After resetAllMocks, vi.fn() returns undefined — restore promise defaults. + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.rename).mockResolvedValue(undefined) + vi.mocked(fs.unlink).mockResolvedValue(undefined) + vi.mocked(fs.rmdir).mockResolvedValue(undefined) + // Existing-target default: a regular 0o644 file. + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + // Staged-file default: a regular file, not a link, so a caller-supplied + // tempPath passes the location and file-type check by default. + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) +} function _stats(mode: number): fsSync.Stats { const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats Object.assign(s, { mode }) @@ -59,15 +86,7 @@ function _stats(mode: number): fsSync.Stats { describe("safeWriteText", () => { beforeEach(() => { - vi.resetAllMocks() - // After resetAllMocks, vi.fn() returns undefined — restore promise defaults. - vi.mocked(fs.mkdir).mockResolvedValue(undefined) - vi.mocked(fs.access).mockResolvedValue(undefined) - vi.mocked(fs.rename).mockResolvedValue(undefined) - vi.mocked(fs.unlink).mockResolvedValue(undefined) - vi.mocked(fs.rmdir).mockResolvedValue(undefined) - // Existing-target default: a regular 0o644 file. - vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + mockDefaults() // Default sync-write behaviour: report that all requested bytes were // written. The Buffer overload passes (fd, buffer, offset, length), // so the fourth argument is the requested length. @@ -577,7 +596,7 @@ describe("safeWriteText", () => { vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" // platform:linux skips DACL entirely so this test focuses on tempPath only await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -604,7 +623,7 @@ describe("safeWriteText", () => { vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) vi.mocked(fsSync.openSync).mockReturnValue(2) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -624,7 +643,7 @@ describe("safeWriteText", () => { }) vi.mocked(fsSync.openSync).mockReturnValue(2) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -642,7 +661,7 @@ describe("safeWriteText", () => { }) vi.mocked(fsSync.openSync).mockReturnValue(2) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" // A target that cannot be stat'd is not a fresh target: publishing with // the default mode would widen a restrictive target through the rename. @@ -674,7 +693,7 @@ describe("safeWriteText", () => { vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o444)) vi.mocked(fsSync.openSync).mockReturnValue(3) - const customTempPath = "/tmp/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -843,7 +862,7 @@ describe("safeWriteText", () => { expect(fsSync.closeSync).toHaveBeenCalledWith(2) }) - it("treats a failed parent-directory fsync as best-effort", async () => { + it("reports a failed parent-directory fsync instead of claiming a durable write", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync) @@ -852,8 +871,13 @@ describe("safeWriteText", () => { throw new Error("EBADF") }) - // the content rename already committed; a missing directory fsync is not fatal - await safeWriteText(targetPath, "data", { platform: "linux" }) + // The content rename committed, so the caller can still find the data at + // the target; what the write cannot claim is that the directory entry + // reached the disk. Returning success here would claim durability the + // filesystem did not grant. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toThrow( + PostCommitDurabilityError, + ) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) @@ -920,3 +944,112 @@ describe("safeWriteText", () => { }) }) }) + +// ── Test 12: lock key, staging path, and post-commit durability ───────────── + +describe("resolveLockKey", () => { + beforeEach(() => mockDefaults()) + + it("canonicalizes the parent directory, not just the file", async () => { + vi.mocked(fs.realpath).mockImplementation(async (target: string) => { + if (target === "/tmp/linkdir/file.json") return "/real/dir/file.json" + if (target === "/real/dir") return "/real/dir" + return target + }) + + // The key is the canonical directory plus the basename, so a symlinked + // ancestor and its referent share one lock. + await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe(path.join("/real/dir", "file.json")) + }) + + it("computes a key for a dangling link, which resolvePublishTarget refuses", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + vi.mocked(fs.readlink).mockImplementation(async (target: string) => + target === "/tmp/linkdir/file.json" ? "referent.json" : undefined, + ) + + // Mid-commit a peer writer renames the referent away and back, so the key + // must still be computable while the link dangles. + await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe( + path.resolve(path.join("/tmp/linkdir", "referent.json")), + ) + }) + + it("terminates on a two-link cycle instead of walking forever", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + // Every readlink answers with the same link, so an unbounded walk would + // never end; the bounded walk returns the key it actually reached. + vi.mocked(fs.readlink).mockImplementation(async () => "a.json") + + await expect(resolveLockKey("/tmp/linkdir/a.json")).resolves.toBe( + path.resolve(path.join("/tmp/linkdir", "a.json")), + ) + expect(fs.readlink).toHaveBeenCalledTimes(8) + }) +}) + +describe("caller-supplied staging path", () => { + beforeEach(() => mockDefaults()) + + it("rejects a staging file outside the target's directory before writing anything", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + + // A rename across filesystems fails with EXDEV, and a path elsewhere lets + // a caller publish an unrelated file onto the target. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/other-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(StagingPathError) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("rejects a staging path that is a symlink", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + + // Renaming a link over the target publishes whatever the link points at. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(StagingPathError) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) +}) + +describe("cleanup before a rollback failure is reported", () => { + beforeEach(() => mockDefaults()) + + it("releases the staged file and its own staging directory before throwing", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + let callCount = 0 + vi.mocked(fs.rename).mockImplementation(async () => { + callCount++ + if (callCount === 1) return // target -> backup + if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails + throw new Error("EACCES") // the rollback rename fails too + }) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( + RollbackFailureError, + ) + + // The backup is what the caller can still recover, so it stays on disk; the + // staging file and this write's own directory must not leak alongside it. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + expect(fs.rmdir).toHaveBeenCalled() + + const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[2] + const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] + const rmdirOrder = vi.mocked(fs.rmdir).mock.invocationCallOrder[0] + expect(unlinkOrder).toBeGreaterThan(failingRenameOrder) + expect(rmdirOrder).toBeGreaterThan(failingRenameOrder) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index b5f82a4f81..9f62d2aed4 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -56,6 +56,41 @@ export class RollbackFailureError extends Error { this.backupPath = backupPath } } + +/** + * A caller-supplied staging path that is not a file this write may publish: it + * sits outside the target's directory (so the commit rename would cross + * filesystems) or is not a regular file. Rejecting it before any write keeps the + * target from being replaced by whatever the path points at. + */ +export class StagingPathError extends Error { + readonly stagingPath: string + + constructor(message: string, stagingPath: string) { + super(message) + this.name = "StagingPathError" + this.stagingPath = stagingPath + } +} + +/** + * The commit rename succeeded but the parent-directory fsync did not, so the + * directory entry is not known to be durable. The content is at the target; the + * caller cannot assume it survives a crash. Reported as its own error so a + * successful return never claims durability the filesystem did not grant. + */ +export class PostCommitDurabilityError extends Error { + readonly targetPath: string + + constructor(targetPath: string, cause: unknown) { + super( + "The rename committed but the parent directory could not be fsynced -- the content is at the target path reported on this error, and the directory entry may not be durable.", + { cause }, + ) + this.name = "PostCommitDurabilityError" + this.targetPath = targetPath + } +} // -- helpers --------------------------------------------------------------- /** Generate a unique temp file name in the given directory. */ @@ -216,6 +251,29 @@ export async function safeWriteText( let stagingDir: string | null = null let tempPath: string if (options?.tempPath) { + // A caller-supplied staging file is only safe when it is the file this + // write is staging, not an arbitrary path. Two properties are checked: + // it must sit beside the resolved target (a rename across filesystems + // fails with EXDEV, and a path elsewhere lets a caller publish an + // unrelated file onto the target), and it must be a regular file rather + // than a link — renaming a link over the target publishes whatever the + // link points at, which is the same trust problem as writing through a + // dangling symlink in resolvePublishTarget. + const supplied = path.resolve(options.tempPath) + if (path.dirname(supplied) !== path.resolve(dirPath)) { + throw new StagingPathError( + `Staging file must sit in the target's directory (${dirPath}), got ${supplied}`, + supplied, + ) + } + const stagingStat = await fs.lstat(supplied) + if (stagingStat.isSymbolicLink() || !stagingStat.isFile()) { + throw new StagingPathError( + `Staging file must be a regular file, not ${stagingStat.isSymbolicLink() ? "a symlink" : "another file type"}`, + supplied, + ) + } + // The caller's own path is used as given; only the check is canonical. tempPath = options.tempPath } else { stagingDir = _stagingDir(dirPath) @@ -227,6 +285,9 @@ export async function safeWriteText( // Non-null only when the win32 step-2 block saved a successful DACL dump: // it gates the step-5 restore and is tracked for the cleanup unlinks. let daclDumpPath: string | null = null + // Set when the rollback itself fails, so cleanup runs before the error that + // reports the partial state is thrown. + let rollbackFailure: unknown = undefined try { // -- Step 1: write content to staging temp file ------------------- @@ -348,8 +409,14 @@ export async function safeWriteText( } finally { fsSync.closeSync(dirFd) } - } catch { - // best-effort: the content rename already committed + } catch (error: unknown) { + // The content rename committed, but the directory entry that + // points at it is not known to be durable. Reporting success + // here would let a caller believe the write survives a crash, + // so the failure is surfaced as its own error: the caller can + // still find the content at the target, it just cannot rely on + // the directory entry having reached the disk. + throw new PostCommitDurabilityError(targetPath, error) } } @@ -394,8 +461,11 @@ export async function safeWriteText( // The content survives only at the backup path now, and the canonical // target is gone. Reporting just the publish failure would leave the // caller with data it cannot find at the expected path, so the - // partial-failure state travels with the error. - throw new RollbackFailureError(originalError, rollbackError, backupPath) + // partial-failure state travels with the error. The staged temp file + // and this write's staging directory are released first: a rollback + // failure is already a hard enough state to reason about without also + // leaking the staging file. + rollbackFailure = rollbackError } } try { @@ -415,6 +485,10 @@ export async function safeWriteText( await fs.unlink(daclDumpPath).catch(() => {}) } + if (rollbackFailure) { + throw new RollbackFailureError(originalError, rollbackFailure, backupPath) + } + throw originalError } } From c4120b0578bb75756a24c7ed59fe331b590588ae Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 21:14:20 +0800 Subject: [PATCH 03/42] fix(file-safety): propagate a non-ENOENT lstat failure in resolvePublishTarget (U1, issue 1375) The resolver may fall back to the given path only when lstat also reports the path as absent. An EACCES or EIO failure says nothing about whether the path is a link, so falling back would publish through a link we were not allowed to inspect. Focused tests added for both branches. --- .../__tests__/safeWriteText.spec.ts | 30 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 9 +++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 66878d8b9d..674df74070 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1053,3 +1053,33 @@ describe("cleanup before a rollback failure is reported", () => { expect(rmdirOrder).toBeGreaterThan(failingRenameOrder) }) }) + +describe("resolvePublishTarget", () => { + beforeEach(() => mockDefaults()) + + it("propagates an lstat failure that is not ENOENT instead of falling back to the link path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockRejectedValue(eacces) + + // A failed lstat says nothing about whether the path is a link, so the + // fallback would publish through a link we were not allowed to inspect. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("still falls back to the given path when lstat also reports the path as absent", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockRejectedValue(enoent) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // The fallback is the resolved path, not the string that was handed in. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), path.resolve(targetPath)) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 9f62d2aed4..d2a55ab349 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -173,7 +173,14 @@ export async function resolvePublishTarget(absoluteFilePath: string): Promise undefined) + // Only a lstat that also reports the path as absent may fall back to the + // given path; a real lstat failure (EACCES, EIO) says nothing about whether + // the path is a link, so falling back would write through a link we were + // simply not allowed to inspect. + const linkStat = await fs.lstat(absoluteFilePath).catch((lstatError: unknown) => { + if (errorCode(lstatError) === "ENOENT") return undefined + throw lstatError + }) if (linkStat?.isSymbolicLink()) throw error return absoluteFilePath }) From 97b599d2e69243e48ff14e434a11cd689278d2a9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:30:44 +0800 Subject: [PATCH 04/42] fix(file-safety): keep the rollback pair typed and the mock stand-ins type-sound compile failed at the unit head on three points: - RollbackFailureError needs a string backupPath, but the throw now happens after cleanup, so the `string | null` narrowing was lost. The failure is now held as { error, backupPath }. - The async lstat stand-in is built on the Stats prototype so it satisfies fsSync.Stats. - The realpath/readlink mocks are typed to the real signatures; the readlink mock answers once because only the link path is read. tsc clean, 50 tests pass, ESLint --max-warnings=0 clean, no suppression change. --- .../__tests__/safeWriteText.spec.ts | 23 +++++++++++-------- src/services/file-safety/safeWriteText.ts | 8 ++++--- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 674df74070..007ff4d3c5 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -58,8 +58,12 @@ function _dirPath(filePath: string): string { } // Minimal Stats stand-in: the SUT only reads `.mode` from it. // Async lstat stand-in: the SUT only asks whether the path is a link or a file. -function _fileStats(isLink: boolean) { - return { isSymbolicLink: () => isLink, isFile: () => !isLink } +// Built on the Stats prototype so the mock value still satisfies fsSync.Stats. +function _fileStats(isLink: boolean): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats + s.isSymbolicLink = () => isLink + s.isFile = () => !isLink + return s } function mockDefaults(): void { @@ -951,10 +955,11 @@ describe("resolveLockKey", () => { beforeEach(() => mockDefaults()) it("canonicalizes the parent directory, not just the file", async () => { - vi.mocked(fs.realpath).mockImplementation(async (target: string) => { - if (target === "/tmp/linkdir/file.json") return "/real/dir/file.json" - if (target === "/real/dir") return "/real/dir" - return target + vi.mocked(fs.realpath).mockImplementation(async (target) => { + const key = String(target) + if (key === "/tmp/linkdir/file.json") return "/real/dir/file.json" + if (key === "/real/dir") return "/real/dir" + return key }) // The key is the canonical directory plus the basename, so a symlinked @@ -966,9 +971,9 @@ describe("resolveLockKey", () => { const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) vi.mocked(fs.realpath).mockRejectedValue(enoent) vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) - vi.mocked(fs.readlink).mockImplementation(async (target: string) => - target === "/tmp/linkdir/file.json" ? "referent.json" : undefined, - ) + // Only the link path is read, so a single answer is enough and keeps the mock's + // return type matching fs.promises.readlink. + vi.mocked(fs.readlink).mockResolvedValue("referent.json") // Mid-commit a peer writer renames the referent away and back, so the key // must still be computable while the link dangles. diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index d2a55ab349..e378cbbae8 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -294,7 +294,9 @@ export async function safeWriteText( let daclDumpPath: string | null = null // Set when the rollback itself fails, so cleanup runs before the error that // reports the partial state is thrown. - let rollbackFailure: unknown = undefined + // Held as a pair so the reported error still names the path the content survived at; + // declaring it as `unknown` alone would lose the string narrowing at the throw site. + let rollbackFailure: { error: unknown; backupPath: string } | null = null try { // -- Step 1: write content to staging temp file ------------------- @@ -472,7 +474,7 @@ export async function safeWriteText( // and this write's staging directory are released first: a rollback // failure is already a hard enough state to reason about without also // leaking the staging file. - rollbackFailure = rollbackError + rollbackFailure = { error: rollbackError, backupPath } } } try { @@ -493,7 +495,7 @@ export async function safeWriteText( } if (rollbackFailure) { - throw new RollbackFailureError(originalError, rollbackFailure, backupPath) + throw new RollbackFailureError(originalError, rollbackFailure.error, rollbackFailure.backupPath) } throw originalError From 435be8b96b7620eefbf799aebfcf7000d33cb1da Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:34:46 +0800 Subject: [PATCH 05/42] rebuild unit u2 on the fixed chain --- .../__tests__/safeWriteJson.lockKey.spec.ts | 183 ++++++++++++++++ src/utils/__tests__/safeWriteJson.test.ts | 196 ++++++++++++++++-- src/utils/safeWriteJson.ts | 167 +++++++-------- 3 files changed, 432 insertions(+), 114 deletions(-) create mode 100644 src/utils/__tests__/safeWriteJson.lockKey.spec.ts diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts new file mode 100644 index 0000000000..33989f8b81 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -0,0 +1,183 @@ +// npx vitest run utils/__tests__/safeWriteJson.lockKey.spec.ts + +import * as os from "os" +import path from "path" +import type { BigIntStats } from "fs" +import * as fs from "fs/promises" +import { acquireFileLock } from "../fileLock" +import { safeWriteJson } from "../safeWriteJson" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" + +vi.mock("../fileLock", () => ({ + acquireFileLock: vi.fn(async () => async () => {}), +})) + +vi.mock("fs/promises", async () => { + const actual = await vi.importActual("fs/promises") + return { ...actual, realpath: vi.fn(), lstat: vi.fn(), readlink: vi.fn() } +}) + +const mockedRealpath = vi.mocked(fs.realpath) +const mockedLstat = vi.mocked(fs.lstat) +const mockedReadlink = vi.mocked(fs.readlink) +const mockedAcquireFileLock = vi.mocked(acquireFileLock) + +const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + +// Each test creates a real temp directory so the real fs calls still work. +// doubles between tests so an implementation from one test cannot carry over. +const createdDirs: string[] = [] +async function makeDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)) + createdDirs.push(dir) + return dir +} + +beforeEach(() => { + mockedRealpath.mockReset() + mockedLstat.mockReset() + mockedReadlink.mockReset() + mockedAcquireFileLock.mockReset() +}) + +afterEach(async () => { + for (const dir of createdDirs) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined) + } + createdDirs.length = 0 +}) + +// Only isSymbolicLink() is consulted by the guard, so the double carries just +// that method. The mocks reject asynchronously: a synchronous throw would bypass +// resolvePublishTarget's catch and skip the ENOENT/symlink branch under test. +const symlinkStat = (target: unknown) => ({ + isSymbolicLink: () => target === currentLink, + // The staging-path check in safeWriteText also asks whether the path is a + // regular file, so the double carries that predicate as well. + isFile: () => target !== currentLink, +}) as unknown as BigIntStats +let currentLink = "" + +describe("safeWriteJson lock key under a peer commit", () => { + it("waits for the peer instead of rejecting, and locks the referent", async () => { + const order: string[] = [] + const dir = await makeDir("lockkey-") + const referent = path.join(dir, "history_item.json") + currentLink = path.join(dir, "link.json") + + // The peer writer has renamed the referent away and has not committed yet, + // so the first resolution fails with ENOENT while lstat still reports a + // symbolic link. A strict resolve here rejects the caller before it can ever + // queue behind the peer, and the caller's delta write is lost. + mockedRealpath + .mockImplementationOnce(async () => { + order.push("resolve-failed") + throw enoent + }) + .mockImplementation(async (target) => { + order.push("resolve") + // The second call happens under the lock, where the peer has committed. + return target === currentLink ? referent : String(target) + }) + mockedLstat.mockImplementation(async (target) => { + order.push("lstat") + return symlinkStat(target) + }) + mockedReadlink.mockImplementation(async (target) => + target === currentLink ? referent : Promise.reject(new Error("not a link")), + ) + mockedAcquireFileLock.mockImplementation(async () => { + order.push("lock") + return async () => {} + }) + + await safeWriteJson(currentLink, { id: "task-1" }) + + // The lock key is the key every other writer to this file uses, so the caller + // queued behind the peer instead of failing before the lock. + expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) + // The trailing lstat is safeWriteText's staging-path check on the temp file + // this write created: it runs after the key was resolved and the lock taken, + // so it does not change which lock the caller queued behind. + expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "resolve", "resolve", "lstat"]) + expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) + }) + + it("releases the lock when the resolution under the lock rejects", async () => { + const order: string[] = [] + let released = false + const dir = await makeDir("lockkey-") + const referent = path.join(dir, "history_item.json") + currentLink = path.join(dir, "link.json") + + // A real dangling link: the walk tolerates it so the caller can queue behind + // the peer, but once the lock is held the strict rejection still applies. A + // rejection outside the protected block would leave the lock held until the + // stale timeout for every other writer to the same file. + mockedRealpath.mockImplementation(async () => { + throw enoent + }) + mockedLstat.mockImplementation(async (target) => { + order.push("lstat") + return symlinkStat(target) + }) + mockedReadlink.mockImplementation(async (target) => + target === currentLink ? referent : Promise.reject(new Error("not a link")), + ) + mockedAcquireFileLock.mockImplementation(async () => { + order.push("lock") + return async () => { + order.push("release") + released = true + } + }) + + await expect(safeWriteJson(currentLink, { id: "task-1" })).rejects.toThrow(enoent) + expect(released).toBe(true) + // The strict rejection is reached through the ENOENT + symlink branch, not + // through a synchronous throw that skips it. + expect(order).toEqual(["lstat", "lock", "lstat", "release"]) + }) + + it("canonicalizes the parent directory when the file itself is not there yet", async () => { + // fs.realpath canonicalizes every component, including a symlinked ancestor + // directory or a Windows 8.3 short name. If the fallback returns the alias + // directory, the key depends on whether the file exists at the moment the key + // is computed, and a writer that resolved the canonical directory takes a + // different lock for the same file. + const aliasDir = path.join(os.tmpdir(), "alias-dir") + const canonicalDir = path.join(os.tmpdir(), "canonical-dir") + const file = path.join(aliasDir, "history_item.json") + mockedRealpath.mockImplementation(async (target) => { + if (target === file) throw enoent + return canonicalDir + }) + mockedLstat.mockImplementation(async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats) + + expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) + }) +}) + +it("does not log a cleanup error when the safety net finds the temp file already gone", async () => { + // safeWriteText removes its own temp file on failure, so the safety net in + // safeWriteJson normally finds it gone. That is the expected outcome, not a + // second failure, and it must not be logged as one. + const dir = await makeDir("cleanup-") + const target = path.join(dir, "history_item.json") + currentLink = "" + mockedRealpath.mockImplementation(async (t) => String(t)) + mockedLstat.mockImplementation(async (t) => symlinkStat(t)) + + const renameSpy = vi.spyOn(fs, "rename").mockRejectedValue(new Error("commit rename failed")) + const unlinkSpy = vi.spyOn(fs, "unlink").mockRejectedValue(enoent) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow("commit rename failed") + + // Only the original failure is reported. + expect(consoleError).toHaveBeenCalledTimes(1) + + renameSpy.mockRestore() + unlinkSpy.mockRestore() + consoleError.mockRestore() +}) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 79d08678a0..245af61910 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -4,6 +4,8 @@ import * as path from "path" import * as os from "os" import { safeWriteJson } from "../safeWriteJson" +import { RollbackFailureError } from "../../services/file-safety/safeWriteText" +import * as lockfile from "proper-lockfile" // Capture actual implementations before the vi.mock factory runs, // so they are never wrapped by vi.fn() — avoids infinite recursion when @@ -312,9 +314,8 @@ describe("safeWriteJson", () => { expect(content).toEqual(newData) }) - // Test for console error suppression during backup deletion - test("should suppress console.error when backup deletion fails", async () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error + // Test for best-effort backup deletion (the backup lifecycle now lives in safeWriteText) + test("does not fail the write when backup deletion fails (orphaned backup is acceptable)", async () => { const initialData = { message: "Initial" } const newData = { message: "New" } @@ -322,18 +323,23 @@ describe("safeWriteJson", () => { // fs.unlink is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn vi.mocked(fs.unlink).mockImplementation(async (filePath: any) => { - if (filePath.toString().includes(".bak_")) { + if (filePath.toString().includes("safeWriteText.bak_")) { throw new Error("Backup deletion failed") } return fsPromisesActuals.unlink!(filePath) }) + // The write must still succeed: backup cleanup is best-effort inside + // safeWriteText and never masks the committed content. await safeWriteJson(currentTestFilePath, newData) - // Verify console.error was called with the expected message - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Successfully wrote"), expect.any(Error)) + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(newData) + + // The orphaned backup is still on disk because its deletion failed. + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) - consoleErrorSpy.mockRestore() vi.mocked(fs.unlink).mockRestore() }) @@ -434,9 +440,9 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // Test for rollback failure scenario - test("should log error and re-throw original if rollback fails", async () => { - const initialData = { message: "Initial, should be lost if rollback fails" } + // Test for rollback failure scenario (the rollback rename now lives in safeWriteText) + test("re-throws the original error when the rollback rename fails, leaving an orphaned backup", async () => { + const initialData = { message: "Initial, orphaned when rollback fails" } const newData = { message: "New content" } await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) @@ -451,20 +457,34 @@ describe("safeWriteJson", () => { // Second call: tempNewFilePath -> filePath (fail) throw new Error("Primary rename failed") } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, also fail) + // Third call: backup -> filePath (rollback, also fail) throw new Error("Rollback rename failed") } return fsPromisesActuals.rename!(oldPath, newPath) }) - // Should throw the original error, not the rollback error - await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") + // The original error must propagate, not the rollback error + // The rollback also failed, so the error reports the partial state: the publish + // failure stays the cause and the backup location is named. + let failure: RollbackFailureError | undefined + await safeWriteJson(currentTestFilePath, newData).catch((e: unknown) => { + if (e instanceof RollbackFailureError) { + failure = e + return + } + throw e + }) + + expect(failure).toBeInstanceOf(RollbackFailureError) + expect(failure?.cause).toBeInstanceOf(Error) + expect(failure?.rollbackError).toBeInstanceOf(Error) + expect(failure?.backupPath).toContain("safeWriteText.bak_") - // Verify console.error was called for the rollback failure - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to restore backup"), - expect.objectContaining({ message: "Rollback rename failed" }), - ) + // The rollback failed inside safeWriteText, so the target is gone and + // the backup is orphaned on disk. + expect(await fileExists(currentTestFilePath)).toBe(false) + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) consoleErrorSpy.mockRestore() }) @@ -542,4 +562,144 @@ describe("safeWriteJson", () => { const content = await readFileContent(currentTestFilePath) expect(content).toEqual({ c: 3 }) }) + + // The commit rename targets the symlink referent. The staged temp file must + // therefore be created beside the RESOLVED target — staging beside the link + // would make the commit rename fail with EXDEV when the referent is on + // another filesystem. (Real symlinks are unavailable in this CI lane, so the + // resolution is simulated by mocking fs.realpath the same way.) + test("stages the temp file beside the symlink referent and commits onto it", async () => { + const referentDir = path.join(tempDir, "referent") + const linkDir = path.join(tempDir, "link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "test-file.json") + const referentPath = path.join(referentDir, "test-file.json") + // Seed the RESOLVED referent with real content (via the actual fs) so the + // write exercises replacement of an EXISTING referent: the lock, the + // backup, and the commit all target the resolved referent. + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) + + // Only the file resolves through the link; the directory is already canonical, + // so the lock key is the referent rather than the alias directory + basename. + vi.spyOn(fs, "realpath").mockImplementation(async (target) => + target === callerPath ? referentPath : String(target), + ) + + await safeWriteJson(callerPath, { after: true }) + + // the temp file was created next to the resolved referent, NOT beside the link + const tempPaths = vi.mocked(fsSyncActual.createWriteStream).mock.calls.map((call) => String(call[0])) + expect(tempPaths.some((p) => p.startsWith(referentDir + path.sep) && p.includes(".new_"))).toBe(true) + expect(tempPaths.some((p) => p.startsWith(linkDir + path.sep))).toBe(false) + + // the content was committed onto the referent + expect(await readFileContent(referentPath)).toEqual({ after: true }) + }) + + // proper-lockfile with realpath:false keys the lock by the given path, so a + // symlink alias and its referent must coordinate through ONE lock on the + // resolved referent — otherwise a concurrent merge through both aliases + // reads the same JSON and overwrites one update. (Real symlinks are + // unavailable in this CI lane, so the resolution is simulated by mocking + // fs.realpath, the same way as the staging test above.) + test("acquires the lock on the resolved referent, not the caller alias", async () => { + vi.resetModules() // fresh module instances so the doMock below is picked up + + const referentDir = path.join(tempDir, "lock-referent") + const linkDir = path.join(tempDir, "lock-link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "locked.json") + const referentPath = path.join(referentDir, "locked.json") + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: 1 })) + + // Only the file resolves through the link; the directory is already canonical, + // so the lock key is the referent rather than the alias directory + basename. + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => (target === callerPath ? referentPath : String(target))) + + // Wrap the real lock in a capturing mock, and drive the two rare error paths + // (the onCompromised callback and a failing release) so they stay covered + // without real lockfile staleness. The callback rethrows by design, so + // the mock swallows that throw and lets the real lock proceed. + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn( + async ( + file: Parameters[0], + options?: Parameters[1], + ) => { + try { + options?.onCompromised?.(new Error("lock compromised (test)")) + } catch { + // onCompromised rethrows by design; swallow so the real lock proceeds. + } + const release = await realLockfile.lock(file, options) + return async () => { + await release() + throw new Error("release failed (test)") + } + }, + ) + const lockMock = lockMockFn as unknown as typeof realLockfile.lock + vi.doMock("proper-lockfile", () => ({ + ...realLockfile, + lock: lockMock, + })) + + // Re-import safeWriteJson so it picks up the mocked proper-lockfile. + const { safeWriteJson: mockedSafeWriteJson } = await import("../safeWriteJson") + + const mergeFn = vi.fn((existing: unknown, incoming: unknown) => ({ + ...(existing as Record), + ...(incoming as Record), + })) + + // Capture the compromise + release-failure logs. + const consoleErrorSpy = vi.spyOn(console, "error") + try { + await mockedSafeWriteJson(callerPath, { added: true }, { merge: mergeFn }) + + // The lock was keyed by the resolved referent — every alias shares it. + expect(lockMock).toHaveBeenCalledTimes(1) + expect(String(lockMockFn.mock.calls[0][0])).toBe(referentPath) + // The merge read the referent's content through that single lock. + expect(mergeFn).toHaveBeenCalledWith({ seed: 1 }, { added: true }) + expect(await readFileContent(referentPath)).toEqual({ seed: 1, added: true }) + // The compromise callback and the failed release were logged, not thrown. + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("was compromised"), expect.any(Error)) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to release lock"), + expect.any(Error), + ) + } finally { + // Cleanup must run even when an assertion fails: a leaked mock + // registration or console spy changes later tests, and vi.unmock + // alone does not reset a module that already imported the mock. + realpathSpy.mockRestore() + vi.unmock("proper-lockfile") + vi.resetModules() + consoleErrorSpy.mockRestore() + } + }) + + // CWE-732 regression: safeWriteJson stages the temp itself and passes it + // via tempPath, so safeWriteText must apply the existing target's mode to + // the staged temp before the atomic rename — otherwise a 0o600 target is + // published as 0o644. POSIX-only assertion (Windows ignores POSIX modes). + test.skipIf(process.platform === "win32")( + "preserves a restrictive 0o600 target mode through the atomic publish", + async () => { + await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify({ before: true })) + fsSyncActual.chmodSync(currentTestFilePath, 0o600) + + await safeWriteJson(currentTestFilePath, { after: true }) + + expect(fsSyncActual.statSync(currentTestFilePath).mode & 0o777).toBe(0o600) + expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) + }, + ) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 7da68b2a7a..bae200dd38 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,6 +4,12 @@ import * as path from "path" import { JsonStreamStringify } from "json-stream-stringify" import { acquireFileLock } from "./fileLock" +import { + resolveLockKey, + resolvePublishTarget, + safeWriteText, + type SafeWriteTextOptions, +} from "../services/file-safety/safeWriteText" /** * Options for safeWriteJson function @@ -32,7 +38,7 @@ export interface SafeWriteJsonOptions { * Safely writes JSON data to a file. * - Creates parent directories if they don't exist * - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path. - * - Writes to a temporary file first. + * - Writes to a temporary file first via JsonStreamStringify streaming. * - If the target file exists, it's backed up before being replaced. * - Attempts to roll back and clean up in case of errors. * - Supports pretty-printing with indentation while maintaining streaming efficiency. @@ -42,7 +48,6 @@ export interface SafeWriteJsonOptions { * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ - async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op @@ -51,38 +56,46 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const dirPath = path.dirname(absoluteFilePath) // Ensure directory structure exists with improved reliability + // Declared outside the protected block so the catch and finally can still name + // the target when the resolution itself rejects. + let resolvedTargetPath: string | undefined + try { - // Create directory with recursive option await fs.mkdir(dirPath, { recursive: true }) - - // Verify directory exists after creation attempt await fs.access(dirPath) } catch (dirError: any) { console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) throw dirError } - // Acquire the lock before any file operations. `acquireFileLock` owns the - // shared advisory lock protocol, so callers that lock the same path with - // it (for example task-history deletion) serialize with this write. - // If lock acquisition fails, it throws immediately. The releaseLock - // remains a no-op, so the finally block in the main file operations - // try-catch-finally won't try to release an unacquired lock if this - // path is taken. - releaseLock = await acquireFileLock(absoluteFilePath) + // Lock key: the symlink referent when the path is an existing symlink, so a + // symlink alias and its referent share one lock. The key must be computable + // while a peer writer is mid-commit (backup mode renames the referent away and + // back), so the walk tolerates a dangling link instead of rejecting it here. + const lockKey = await resolveLockKey(absoluteFilePath) - // Variables to hold the actual paths of temp files if they are created. + // Acquire the lock before any file operations. If acquisition fails it throws + // immediately, and releaseLock stays a no-op so the finally block does not try + // to release an unacquired lock. + releaseLock = await acquireFileLock(lockKey) + + // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null - let actualTempBackupFilePath: string | null = null try { + // Resolve the publish target under the lock: the peer has committed by now, so + // the strict dangling-link rejection still applies to a real dangling link. It + // must stay inside the protected block, otherwise a rejection here leaves the + // advisory lock held until the stale timeout for every other writer. + resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // If a merge callback was provided, read the current file under the lock // and let the caller merge before we write. Must be inside try/finally // so a throwing merge still releases the lock. if (options?.merge) { let existing: unknown = null try { - existing = JSON.parse(await fs.readFile(absoluteFilePath, "utf8")) + existing = JSON.parse(await fs.readFile(resolvedTargetPath, "utf8")) } catch (error: unknown) { const code = error && typeof error === "object" && "code" in error ? (error as { code: string }).code : undefined @@ -93,111 +106,73 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso data = options.merge(existing, data) } - // Step 1: Write data to a new temporary file. + // Step 1: Write data to a new temporary file via JSON streaming. + // Stage it beside the *resolved* target (the symlink referent when the path is + // a symlink; resolvedTargetPath above): safeWriteText commits by renaming + // onto that referent, and a rename across filesystems would fail with EXDEV. actualTempNewFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, + path.dirname(resolvedTargetPath), + ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) - // Step 2: Check if the target file exists. If so, rename it to a backup path. - try { - // Check for target file existence - await fs.access(absoluteFilePath) - // Target exists, create a backup path and rename. - actualTempBackupFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, - ) - await fs.rename(absoluteFilePath, actualTempBackupFilePath) - } catch (accessError: any) { - // Explicitly type accessError - if (accessError.code !== "ENOENT") { - // An error other than "file not found" occurred during access check. - throw accessError - } - // Target file does not exist, so no backup is made. actualTempBackupFilePath remains null. + // Step 2: Delegate backup + commit + rollback to safeWriteText with the + // pre-written temp path. backup:true keeps the old safeWriteJson + // semantics (target -> backup before commit, rollback on failure) and + // keeps the target in place until safeWriteText captures its Windows + // DACL (safeWriteText dumps the DACL before its own backup rename and + // restores it onto the directory after the commit rename). + const textOptions: SafeWriteTextOptions = { + tempPath: actualTempNewFilePath, + backup: true, } - // Step 3: Rename the new temporary file to the target file path. - // This is the main "commit" step. - await fs.rename(actualTempNewFilePath, absoluteFilePath) + await safeWriteText(resolvedTargetPath, "", textOptions) - // If we reach here, the new file is successfully in place. - // The original actualTempNewFilePath is now the main file, so we shouldn't try to clean it up as "temp". - // Mark as "used" or "committed" + // If we reach here, the new file is successfully in place and any + // backup has already been handled by safeWriteText. actualTempNewFilePath = null - - // Step 4: If a backup was created, attempt to delete it. - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - // Mark backup as handled - actualTempBackupFilePath = null - } catch (unlinkBackupError) { - // Log this error, but do not re-throw. The main operation was successful. - // actualTempBackupFilePath remains set, indicating an orphaned backup. - console.error( - `Successfully wrote ${absoluteFilePath}, but failed to clean up backup ${actualTempBackupFilePath}:`, - unlinkBackupError, - ) - } - } } catch (originalError) { - console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) + console.error( + `Operation failed for ${resolvedTargetPath ?? absoluteFilePath}: [Original Error Caught]`, + originalError, + ) const newFileToCleanupWithinCatch = actualTempNewFilePath - const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath - - // Attempt rollback if a backup was made - if (backupFileToRollbackOrCleanupWithinCatch) { - try { - await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) - // Mark as handled, prevent later unlink of this path - actualTempBackupFilePath = null - } catch (rollbackError) { - // actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch - console.error( - `[Catch] Failed to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}:`, - rollbackError, - ) - } - } - // Cleanup the .new file if it exists + // A failed safeWriteText already rolled the backup (if any) back to + // the target path. Clean up the .new file if it still exists + // (safeWriteText also cleans up its tempPath on failure; this is a + // safety net in case its cleanup missed it). if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, - cleanupError, - ) + } catch (cleanupError: unknown) { + // The expected case: safeWriteText already removed its own temp file, so a + // missing file here is not a cleanup failure worth logging. Returning would + // also swallow the original error the caller needs. + const isAbsent = + typeof cleanupError === "object" && + cleanupError !== null && + "code" in cleanupError && + cleanupError.code === "ENOENT" + if (!isAbsent) { + console.error( + `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, + cleanupError, + ) + } } } - // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, - cleanupError, - ) - } - } throw originalError // This MUST be the error that rejects the promise. } finally { // Release the lock in the main finally block. try { - // releaseLock will be the actual unlock function if lock was acquired, - // or the initial no-op if acquisition failed. await releaseLock() } catch (unlockError) { - // Do not re-throw here, as the originalError from the try/catch (if any) is more important. - console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) + console.error(`Failed to release lock for ${resolvedTargetPath ?? absoluteFilePath}:`, unlockError) } } } From 625a976dc2bee3b7d3446e1fdaceaf4e2d4fc5ec Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:47:25 +0800 Subject: [PATCH 06/42] chore(lint): prune the safeWriteJson suppression this unit earns The any usage this entry covered is gone in the rewritten file, so the count drops 4 -> 3. eslint --prune-suppressions --max-warnings=0 confirms it. --- src/eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 583485c628..53ce332bb3 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1716,7 +1716,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 } }, "utils/tts.ts": { From 4e2de13ff3a535db87802e97b7ea37e409dd0933 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:47:37 +0800 Subject: [PATCH 07/42] rebuild unit u3 on the fixed chain --- .../__tests__/observationRegistry.spec.ts | 108 ++++++++++++++++++ src/core/task/observationRegistry.ts | 59 ++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/core/task/__tests__/observationRegistry.spec.ts create mode 100644 src/core/task/observationRegistry.ts diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts new file mode 100644 index 0000000000..a3c55ebc6d --- /dev/null +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest" + +import { ObservationRegistry } from "../observationRegistry" + +describe("ObservationRegistry", () => { + it("observe → get returns the recorded version and observedAt", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000") + + const obs = reg.get("/a/b/c.ts") + expect(obs).toBeDefined() + expect(obs!.version).toBe("1:2:300:4000000000:5000000000") + expect(typeof obs!.observedAt).toBe("number") + }) + + it("re-observe replaces the entry with a fresh observedAt", () => { + vi.useFakeTimers() + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + const first = reg.get("/a/b/c.ts")! + expect(first.version).toBe("v1") + + vi.advanceTimersByTime(50) + reg.observe("/a/b/c.ts", "v2") + const second = reg.get("/a/b/c.ts")! + expect(second.version).toBe("v2") + expect(second.observedAt).toBeGreaterThan(first.observedAt) + + vi.useRealTimers() + }) + + it("has returns true for observed paths, false otherwise", () => { + const reg = new ObservationRegistry() + reg.observe("/x.ts", "t1") + expect(reg.has("/x.ts")).toBe(true) + expect(reg.has("/y.ts")).toBe(false) + }) + + it("size reflects the number of observed entries", () => { + const reg = new ObservationRegistry() + expect(reg.size).toBe(0) + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + expect(reg.size).toBe(2) + }) + + it("clear removes all entries and resets size to 0", () => { + const reg = new ObservationRegistry() + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + reg.clear() + expect(reg.size).toBe(0) + expect(reg.get("/a.ts")).toBeUndefined() + expect(reg.has("/b.ts")).toBe(false) + }) + + it("get on empty registry returns undefined", () => { + const reg = new ObservationRegistry() + expect(reg.get("/any.ts")).toBeUndefined() + }) + + it("separate instances are independent — observing in one does not appear in the other", () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")).toBeDefined() + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) + + describe("completeness scope (S4b follow-up #46)", () => { + it("defaults to a complete observation when the read scope is not given", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + + expect(reg.get("/a/b/c.ts")!.complete).toBe(true) + }) + + it("records a partial observation when the read only returned a view of the file", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1", false) + + expect(reg.get("/a/b/c.ts")!.complete).toBe(false) + }) + + it("re-observing replaces the entry's completeness with the new read's scope", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1", false) + reg.observe("/a/b/c.ts", "v2") + + const obs = reg.get("/a/b/c.ts")! + expect(obs.version).toBe("v2") + expect(obs.complete).toBe(true) + }) + + it("re-observing with a partial scope downgrades a previously complete entry", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + reg.observe("/a/b/c.ts", "v2", false) + + const obs = reg.get("/a/b/c.ts")! + expect(obs.version).toBe("v2") + expect(obs.complete).toBe(false) + }) + }) +}) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts new file mode 100644 index 0000000000..0ef9115f21 --- /dev/null +++ b/src/core/task/observationRegistry.ts @@ -0,0 +1,59 @@ +/** + * Per-task file observation registry (upstream epic #1375, phase A2). + * + * Each Task owns its own instance so parent and subtask observations are + * independent. The S4 guarded-write will compare these versions against the + * token recomputed pre-write to detect stale reads or file replacement. + * + * Pure in-memory — zero I/O, no dependencies. The S4 guarded-write consults + * these observations for the version check and for the completeness check that + * gates a full-file replacement. + */ + +export interface FileObservation { + /** Version token derived from on-disk fs.stat (bigint mode). */ + version: string + /** Millisecond timestamp when the observation was recorded. */ + observedAt: number + /** + * Whether the read that produced this observation returned the complete + * file. A slice, line-range, truncated, or indentation-block read returns + * only a view of the file; such an observation authorizes targeted edits + * on the view the model saw, but never a full-file replacement. + */ + complete: boolean +} + +export class ObservationRegistry { + private readonly entries = new Map() + + /** + * Record an observation for a file at its absolute path. + * + * Re-observing replaces the entry with a fresh observedAt timestamp, the + * new version token, and the read's completeness. `complete` defaults to + * true for callers that read the whole file themselves (spec doubles, + * WriteToFileTool). A caller whose read is internal to a targeted edit must + * carry the model's prior completeness instead, so the tool's own read cannot + * upgrade a partial read into authority for a full-file replacement. + */ + observe(absolutePath: string, version: string, complete: boolean = true): void { + this.entries.set(absolutePath, { version, observedAt: Date.now(), complete }) + } + + get(absolutePath: string): FileObservation | undefined { + return this.entries.get(absolutePath) + } + + has(absolutePath: string): boolean { + return this.entries.has(absolutePath) + } + + clear(): void { + this.entries.clear() + } + + get size(): number { + return this.entries.size + } +} From 60376ca186af0bd85b439f4825bc6db1f736fc7d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:34:05 +0800 Subject: [PATCH 08/42] fix(task): declare the observation registry on Task in this unit The read tools record the observed on-disk version through task.observationRegistry, but the field was only declared in a later unit, so at this head the call dereferences undefined and the mocked e2e run fails on the read_file smoke tests. The registry is introduced by this unit, so the field belongs here. tsc clean on this unit, 11 observationRegistry tests pass, ESLint --max-warnings=0 clean. --- src/core/task/Task.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4de2b84590..5fbac2dd3d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -111,6 +111,7 @@ import { buildNativeToolsArrayWithRestrictions } from "./build-tools" import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" +import { ObservationRegistry } from "./observationRegistry" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" @@ -286,6 +287,10 @@ export class Task extends EventEmitter implements TaskLike { readonly instanceId: string readonly metadata: TaskMetadata + // The observed on-disk version of each file this task has read. Declared here so the + // read tools can record it; a write guard later compares a token against this registry. + readonly observationRegistry = new ObservationRegistry() + todoList?: TodoItem[] readonly rootTask: Task | undefined = undefined From 77eb0a48678b31fb72d9f1ea59a6587c64571035 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:48:10 +0800 Subject: [PATCH 09/42] rebuild unit u4 on the fixed chain --- src/core/tools/ReadFileTool.ts | 93 ++- src/core/tools/__tests__/readFileTool.spec.ts | 784 +++++++++++++++++- .../misc/__tests__/indentation-reader.spec.ts | 43 + src/integrations/misc/indentation-reader.ts | 12 +- 4 files changed, 922 insertions(+), 10 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..ba1be7deaf 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,13 +16,14 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" +import { versionTokenOfStat } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { getReadablePath } from "../../utils/path" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader" -import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" +import { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH } from "../prompts/tools/native-tools/read_file" import type { ToolUse, PushToolResult } from "../../shared/tools" import { @@ -214,14 +215,36 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read text file content with lossy UTF-8 conversion // Reading as Buffer first allows graceful handling of non-UTF8 bytes // (they become U+FFFD replacement characters instead of throwing) + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const buffer = await fs.readFile(fullPath) const fileContent = buffer.toString("utf-8") - const result = this.processTextFile(fileContent, entry) + // A lossy decode is not the whole file: the model never saw those bytes. + const lossyDecode = !Buffer.from(fileContent).equals(buffer) + // S4b follow-up (#46 / epic #1375): processTextFile reports whether the + // returned content is the whole file; the observation below records that + // scope so the write guard can deny full-file updates built on a partial view. + const processed = this.processTextFile(fileContent, entry) await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard. + // The token is captured before AND after the read; the target is observed only + // when both match — a mutation between the two stats means the content the model + // received is not the on-disk state, and observing it would let a later write + // match a token the model never saw. A stat failure leaves the target + // unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken, processed.complete && !lossyDecode) + } + } + updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${result}`, + nativeContent: `File: ${relPath}\n${processed.content}`, }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) @@ -265,8 +288,14 @@ export class ReadFileTool extends BaseTool<"read_file"> { /** * Process a text file according to the requested mode. + * + * Returns the content string plus whether that content is the complete + * file (S4b follow-up #46 / epic #1375): slice mode is complete only + * when it starts at line 1, returns every line, and was not truncated; + * indentation mode is never complete because it returns semantic blocks + * of the file, not the file itself. */ - private processTextFile(content: string, entry: InternalFileEntry): string { + private processTextFile(content: string, entry: InternalFileEntry): { content: string; complete: boolean } { const mode = entry.mode || "slice" if (mode === "indentation") { @@ -299,7 +328,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)` } - return output + // Indentation mode returns semantic blocks: never a complete file view. + return { content: output, complete: false } } // Slice mode (default): simple offset/limit reading @@ -322,11 +352,28 @@ export class ReadFileTool extends BaseTool<"read_file"> { To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. ${result.content}` + if (result.hasClippedLines) { + // The slice cut lines off and also clipped long lines inside it, so both + // notices belong to the response. + output += `\nNote: Some lines in this view exceed ${MAX_LINE_LENGTH} characters and were clipped in this view.` + } + } else if (result.hasClippedLines) { + // Every line was returned, so there is no later offset to read: report the + // clipping without a next-offset hint, and keep the read incomplete so a + // full-file replacement cannot be built from a clipped line. + output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full. + ${result.content}` } else if (result.returnedLines === 0) { output = "Note: File is empty" } - return output + // Complete only when the slice starts at line 1, returned every line, and + // showed every line in full (returnedLines === totalLines follows from the + // first two conditions): a partial start, a truncated tail, or a clipped + // line means the model did not see the whole file. + const complete = offset0 === 0 && !result.wasTruncated && !result.hasClippedLines + + return { content: output, complete } } /** @@ -768,9 +815,20 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Read text file - const rawContent = await fs.readFile(fullPath, "utf8") + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + const rawBuffer = await fs.readFile(fullPath) + const rawContent = rawBuffer.toString("utf-8") + // Same contract: a lossy decode is a partial view. + const lossyDecode = !Buffer.from(rawContent).equals(rawBuffer) // Handle line ranges if specified + // S4b follow-up (#46 / epic #1375): a line-range read returns only the requested + // ranges, and a slice truncated to DEFAULT_LINE_LIMIT returns only the head of + // the file — record such observations as partial so the write guard denies a + // full-file update built on them. + let readComplete = false let content: string if (entry.lineRanges && entry.lineRanges.length > 0) { const lines = rawContent.split("\n") @@ -790,8 +848,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read with default limits using slice mode const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) content = result.content + readComplete = !result.wasTruncated && !result.hasClippedLines if (result.wasTruncated) { content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` + if (result.hasClippedLines) { + // Both notices: the slice was truncated and a line inside it was + // clipped. + content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]` + } + } else if (result.hasClippedLines) { + content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]` } } @@ -799,6 +865,19 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Track file in context await task.fileContextTracker.trackFileContext(relPath, "read_tool") + + // A2 (plan #33 / epic #1375): mirror the native path — record the observed + // on-disk version so legacy-format reads also feed the future write guard. + // Observe only when the pre-read and post-read tokens match (a mutation between + // them means the returned content is not the on-disk state). A stat failure + // leaves the target unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken, readComplete && !lossyDecode) + } + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..34d69ed4cc 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -13,10 +13,16 @@ */ import path from "path" +import type { Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" +import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { computeVersionToken } from "../../../utils/versionToken" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -136,6 +142,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -143,6 +150,9 @@ function createMockTask(options: MockTaskOptions = {}) { return { cwd: "/test/workspace", + // Mirror Task: every task always owns an observation registry (A2, #1375). + // Tests asserting on observations pass their own instance via options. + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), api: { getModel: vi.fn().mockReturnValue({ info: { supportsImages }, @@ -187,7 +197,18 @@ describe("ReadFileTool", () => { vi.clearAllMocks() // Default mock implementations - mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) + // The stat default carries BigIntStats fields (A2, epic #1375): reads now + // token-ize the pre/post stats, so the default must look like a real bigint stat. + // Tests overriding it do so per-call with mockResolvedValue(Once). + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) mockedIsBinaryFile.mockResolvedValue(false) mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) mockedReadWithSlice.mockReturnValue({ @@ -839,7 +860,7 @@ describe("ReadFileTool", () => { mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) // fs.readFile with "utf8" encoding returns a string, not a Buffer - mockedFsReadFile.mockResolvedValue("line1\nline2\nline3\nline4\nline5" as any) + mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3\nline4\nline5")) await readFileTool.execute( { files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] } as any, @@ -1489,5 +1510,764 @@ describe("ReadFileTool", () => { expect(mockTask.didToolFailInCurrentTurn).toBe(true) }) + + describe("observation registry", () => { + it("records an observation on successful read of an existing file", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Override the beforeEach default stat mock with proper BigIntStats. + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + // Spy on observe to capture the exact key used (Windows path.resolve may use backslashes). + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + + // Verify the tool called observe exactly once with a valid token. + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("existing.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + + // Verify get() returns the same data using the spy-captured key. + const obs = reg.get(calledPath) + expect(obs).toBeDefined() + expect(obs!.version).toBe(calledVersion) + }) + + it("a failed read (absent path) leaves the registry size 0 and does not throw", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "missing.ts" }, mockTask as unknown as Task, callbacks) + + // observationRegistry is guaranteed present because we passed it in createMockTask. + const reg = mockTask.observationRegistry + expect(reg).toBeDefined() + expect(reg!.size).toBe(0) + }) + + it("records an observation for legacy-format reads of existing files", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Typed legacy (pre-refactor) params: the multi-file format with the + // _legacyFormat discriminant (see LegacyReadFileParams). + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("legacy.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + }) + + it("does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // A mutation lands mid-read: the post-read stat differs. + const postStats = { ...preStats, size: BigInt(301) } + + // Call order: directory check, pre-read stat, post-read stat. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce(postStats as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "mutated.ts" }, mockTask as unknown as Task, callbacks) + + // The read itself succeeded, but the target stays unobserved: the content the + // model received is not the on-disk state, so observing it would let a later + // write match a token the model never saw. + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("leaves the target unobserved without failing the read when the pre-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + // The read still succeeds — a stat failure never fails the read. + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + + it("leaves the target unobserved without failing the read when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "post-stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: post-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + + it("legacy format: does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Call order: directory check, pre-read stat, post-read stat (mutated). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce({ ...preStats, size: BigInt(301) } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-mutated.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("legacy format: leaves the target unobserved when a stat fails without failing the read", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: legacy-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + it("legacy format: leaves the target unobserved when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-post-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: legacy-post-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + it("two separate Task-owned registries are independent", async () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) + }) + + describe("read completeness scope (S4b follow-up #46)", () => { + // The stat mock only implements the members the tool and versionToken read. + const bigintStats = (): Stats => + ({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + }) as unknown as Stats + + it("native: a full, untruncated slice read from line 1 records a complete observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "full.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion, calledComplete] = observeSpy.mock.calls[0] + expect(calledPath).toContain("full.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + expect(calledComplete).toBe(true) + expect(reg.get(calledPath)!.complete).toBe(true) + }) + + it("native: a truncated slice read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a", + returnedLines: 1, + totalLines: 5, + wasTruncated: true, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "trunc.ts", offset: 1, limit: 1 }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + + it("native: a full read whose line content was clipped records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "clipped.ts" }, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + // Every line was returned, so the notice must not point at a next + // offset that is beyond the file. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).not.toContain("To read more") + // The notice is added on top of the read, it does not replace it. + expect(pushed).toContain("1 | a") + }) + + it("native: a truncated slice that also clipped a line reports both notices", async () => { + // A long line inside a slice that also cut lines off is a plausible case, + // and the response has to say both things. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 3, + wasTruncated: true, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "both.ts" }, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("Showing lines 1-2 of 3 total lines") + expect(pushed).toContain("clipped in this view") + }) + + it("native: an offset read that is not truncated still records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "4 | d\n5 | e", + returnedLines: 2, + totalLines: 5, + wasTruncated: false, + includedRanges: [[4, 5]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "offset.ts", offset: 4, limit: 2 }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("native: an indentation-mode block read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("function f() { return 1 }")) + mockedReadWithIndentation.mockReturnValue({ + content: "10 | function f() {", + wasTruncated: false, + includedRanges: [[10, 20]], + totalLines: 100, + returnedLines: 11, + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "indent.ts", mode: "indentation" }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("legacy: a line-range read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "ranges.ts", lineRanges: [{ start: 2, end: 4 }] }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledPath).toContain("ranges.ts") + expect(calledComplete).toBe(false) + }) + + it("legacy: a full, untruncated slice read records a complete observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-full.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(true) + + // Nothing was omitted and nothing was clipped, so no note is added. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).not.toContain("clipped in this view") + expect(pushed).not.toContain("total lines") + }) + + it("legacy: a full read with a clipped line records a partial observation and reports the clipping", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-clipped.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + // Every line was returned, so the note reports the clipping instead of + // a showing-N-of-N count that would point past the file. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).not.toContain("showing 2 of 2 total lines") + }) + + it("legacy: a truncated slice that also clipped a line reports both notices", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 3, + wasTruncated: true, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-both.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("showing 2 of 3 total lines") + expect(pushed).toContain("clipped in this view") + }) + + it("legacy: a slice truncated to the default limit records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a", + returnedLines: 1, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-trunc.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + // Lines were omitted here, so the note reports the omitted range rather + // than clipping. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("showing 1 of 5000 total lines") + }) + + it("native: a read whose bytes did not survive the UTF-8 decode records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // 0xFF is not valid UTF-8, so the model receives U+FFFD instead of the byte. + const raw = Buffer.from([0x61, 0xff]) + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(raw) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\uFFFD", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "lossy.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + + it("legacy: a read whose bytes did not survive the UTF-8 decode records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const raw = Buffer.from([0x61, 0xff]) + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(raw) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\uFFFD", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-lossy.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + }) }) }) diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts index d46cb54277..e9b27e7191 100644 --- a/src/integrations/misc/__tests__/indentation-reader.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest" +import { MAX_LINE_LENGTH } from "../../../core/prompts/tools/native-tools/read_file" import { parseLines, formatWithLineNumbers, @@ -279,11 +280,45 @@ describe("readWithSlice", () => { expect(result.wasTruncated).toBe(true) }) + it("reports a clipped line separately from omitted lines", () => { + // Every line is returned, but formatWithLineNumbers clips a line longer + // than MAX_LINE_LENGTH, so the model did not see the whole file. + const lines = ["x".repeat(MAX_LINE_LENGTH + 10), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.wasTruncated).toBe(false) + expect(result.hasClippedLines).toBe(true) + }) + + it("keeps a slice complete when a line is exactly at the length cap", () => { + // formatWithLineNumbers clips only lines strictly longer than the cap, so a + // line at exactly MAX_LINE_LENGTH is shown in full and the read is complete. + const lines = ["x".repeat(MAX_LINE_LENGTH), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.wasTruncated).toBe(false) + expect(result.hasClippedLines).toBe(false) + }) + + it("flags clipping when any line is clipped, not only when every line is", () => { + // The first line is clipped and the second is shown in full: some lines are + // a partial view even though every line was returned. + const lines = ["y".repeat(MAX_LINE_LENGTH + 1), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.hasClippedLines).toBe(true) + }) + it("should handle offset beyond file end", () => { const result = readWithSlice(SIMPLE_CODE, 1000, 10) expect(result.returnedLines).toBe(0) expect(result.content).toContain("Error") + // No line was returned, so nothing could have been clipped. + expect(result.hasClippedLines).toBe(false) }) it("should handle negative offset", () => { @@ -297,6 +332,14 @@ describe("readWithSlice", () => { // ─── readWithIndentation Tests ──────────────────────────────────────────────── describe("readWithIndentation", () => { + it("reports an out-of-range anchor as an error with no clipping", () => { + const result = readWithIndentation(SIMPLE_CODE, { anchorLine: 1000 }) + + expect(result.content).toContain("out of range") + expect(result.returnedLines).toBe(0) + expect(result.hasClippedLines).toBe(false) + }) + describe("basic block extraction", () => { it("should extract content around the anchor line", () => { const result = readWithIndentation(PYTHON_CODE, { diff --git a/src/integrations/misc/indentation-reader.ts b/src/integrations/misc/indentation-reader.ts index aecabd5982..5cbd23d168 100644 --- a/src/integrations/misc/indentation-reader.ts +++ b/src/integrations/misc/indentation-reader.ts @@ -58,8 +58,10 @@ export interface IndentationReadResult { totalLines: number /** Lines actually returned */ returnedLines: number - /** Whether output was truncated due to limit */ + /** Whether output was truncated because lines were omitted */ wasTruncated: boolean + /** Whether any returned line was clipped by the per-line length cap */ + hasClippedLines?: boolean } // ─── Constants ──────────────────────────────────────────────────────────────── @@ -306,6 +308,7 @@ export function readWithIndentation(content: string, options: IndentationReadOpt totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -448,6 +451,7 @@ export function readWithSlice( totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -455,6 +459,11 @@ export function readWithSlice( const endIdx = Math.min(offset + limit, totalLines) const selectedLines = lines.slice(offset, endIdx) const wasTruncated = endIdx < totalLines + // A returned line can still be a partial view: formatWithLineNumbers clips a + // line longer than MAX_LINE_LENGTH, so a slice that returned every line may + // still hide content. Clipping is reported separately from omission so the + // caller does not suggest a next offset that is beyond the file. + const hasClippedLines = selectedLines.some((line) => line.content.length > MAX_LINE_LENGTH) // Format output const formattedContent = formatWithLineNumbers(selectedLines) @@ -465,5 +474,6 @@ export function readWithSlice( totalLines, returnedLines: selectedLines.length, wasTruncated, + hasClippedLines, } } From d7eab3d994f869077f3ab6f87667c9d2d24e1113 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:49:24 +0800 Subject: [PATCH 10/42] chore(lint): prune the readFileTool.spec suppression this unit earns The two any usages this entry covered are gone in the rewritten spec, so the count drops 98 -> 96. eslint --prune-suppressions --max-warnings=0 confirms it. --- src/eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 53ce332bb3..d607509cdc 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -976,7 +976,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 98 + "count": 96 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { From ca636d6f97b8e9fa331e26252d9a3179c7c3a487 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 22:49:49 +0800 Subject: [PATCH 11/42] rebuild unit u5 on the fixed chain --- src/core/tools/__tests__/guardedWrite.spec.ts | 859 ++++++++++++++++++ src/core/tools/guardedWrite.ts | 418 +++++++++ 2 files changed, 1277 insertions(+) create mode 100644 src/core/tools/__tests__/guardedWrite.spec.ts create mode 100644 src/core/tools/guardedWrite.ts diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts new file mode 100644 index 0000000000..e111bd74db --- /dev/null +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -0,0 +1,859 @@ +/** + * Tests for the guarded-write compare-and-swap core (upstream epic #1375, + * phase A4a). + * + * Covers guard selection through the S2 observation registry, version-token + * CAS, remediation messages, and the per-absolute-path FIFO chain: FIFO + * ordering, last-write-wins for same-task writes through the post-publish + * observation refresh, no wedge after a rejected link, and independence across + * paths. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { describe, expect, it, beforeEach, vi } from "vitest" + +import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain, GuardRejectedError } from "../guardedWrite" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../../utils/versionToken" +import { withFileLock } from "../../../utils/fileLock" +import { resolveLockKey } from "../../../services/file-safety/safeWriteText" +import { ObservationRegistry } from "../../task/observationRegistry" +import type { Task } from "../../task/Task" + +// -- Mocks ------------------------------------------------------------------- + +vi.mock("fs/promises", () => ({ + access: vi.fn(), + stat: vi.fn(), +})) + +vi.mock("../../../utils/versionToken", () => ({ + computeVersionToken: vi.fn(), +})) + +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn(), + resolveLockKey: vi.fn(async (p: string) => p), +})) + +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(), +})) + +const mockedWithFileLock = vi.mocked(withFileLock) +const mockedResolveLockKey = vi.mocked(resolveLockKey) +const mockedFsAccess = vi.mocked(fs.access) +const mockedComputeVersionToken = vi.mocked(computeVersionToken) +const mockedSafeWriteText = vi.mocked(safeWriteText) + +// -- Fixtures ---------------------------------------------------------------- + +const WORKSPACE = "/test/workspace" + +/** Resolve a fixture path the same way guardedWrite resolves task.cwd-relative paths. */ +const abs = (relPath: string): string => path.resolve(WORKSPACE, relPath) + +interface MockTaskOptions { + cwd?: string + observationRegistry?: ObservationRegistry + abort?: boolean +} + +/** + * Minimal structural Task: guardedWrite only reads task.cwd and + * task.observationRegistry. The real Task constructor needs the full provider + * machinery, so a single documented double cast stands in for the class. + */ +function createMockTask(options: MockTaskOptions = {}): Task { + const task = { + cwd: options.cwd ?? WORKSPACE, + abort: options.abort ?? false, + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), + } + return task as unknown as Task +} + +// -- Tests ------------------------------------------------------------------- + +describe("guardedWrite (S4a, epic #1375)", () => { + beforeEach(() => { + vi.resetAllMocks() + mockedWithFileLock.mockImplementation((filePath, operation) => operation(path.resolve(filePath))) + resetChain() + }) + + describe("unobserved create", () => { + it("succeeds when the file is absent and publishes via safeWriteText", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("publishes caller-supplied bytes unchanged", async () => { + // The extension host hands over bytes already encoded by VS Code's + // codec; the guard must pass them to the publish primitive as they are. + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask() + + await guardedWrite(task, "bytes.txt", Buffer.from([0x00, 0x68]), "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("bytes.txt"), Buffer.from([0x00, 0x68])) + }) + + it("records an unobserved create as complete so a later full-file update is allowed", async () => { + // Nothing was read, so the model supplied the whole file: the post-publish + // refresh must record completeness, otherwise the next update would be + // rejected as a partial read. + const reg = new ObservationRegistry() + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(reg.get(abs("new-file.txt"))?.complete).toBe(true) + }) + + it("carries a caller-supplied completeness through the publish for a fresh destination", async () => { + // A move publishes content built from a view of another file. Recording the + // create as complete would hand the model authority over source lines it never + // read, so the caller's completeness has to survive the refresh. + const reg = new ObservationRegistry() + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "new-file.txt", "hello", "create", false) + + expect(reg.get(abs("new-file.txt"))?.complete).toBe(false) + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "create")).rejects.toThrow( + "File already exists at " + + "existing.txt" + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows I/O errors that are not ENOENT verbatim (no guard verdict on access failure)", async () => { + const failures = [{ code: "EACCES" }, null, "volume offline", new Error("EIO-ish failure")] + for (const failure of failures) { + mockedFsAccess.mockRejectedValueOnce(failure) + await expect(createIfAbsent(abs("io-error.txt"), "x", "io-error.txt")).rejects.toBe(failure) + } + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("deleted-after-read target", () => { + it("normalizes an ENOENT from the version token into the re-read remediation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("vanished.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + + // The file was deleted after the read: the token computation fails + // with a raw ENOENT, which the guard must convert into the standard + // re-read-then-retry contract. + mockedComputeVersionToken.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "vanished.txt", "next", "update")).rejects.toThrow( + "File was deleted after it was read", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows non-ENOENT token failures verbatim from replaceIfVersion", async () => { + const failure = { code: "EACCES" } + mockedComputeVersionToken.mockRejectedValueOnce(failure) + + await expect(replaceIfVersion(abs("locked.txt"), "v1", "next", "locked.txt")).rejects.toBe(failure) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + describe("unobserved update", () => { + it("succeeds when the file is absent (same create guard)", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "update")).rejects.toThrow( + "File already exists at " + + "existing.txt" + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed create", () => { + it("recreates a file that vanished after the read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("gone.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "gone.txt", "back", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back") + }) + + it("goes through the version guard when the file still exists", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "kept.txt", "rewritten", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "kept.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("defers to the version guard when the access check is denied (not ENOENT)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("locked.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "EACCES" }) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "locked.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed update (version CAS)", () => { + it("publishes when the on-disk version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "new content", "update") + + expect(mockedComputeVersionToken).toHaveBeenCalledWith(abs("doc.txt")) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + }) + + it("fails with the stale remediation suffix when the version moved - nothing published", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed update (read completeness, S4b follow-up #46)", () => { + it("rejects a full-file update when only a partial read observed the file - no I/O, nothing published", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("publishes a full-file update when the observation is complete and the version matches", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "new content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + }) + + it("leaves edit-kind publishes unaffected by a partial observation - the model saw the edited region", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + }) + + it("keeps a partial observation partial after an edit so a later full replacement is rejected", async () => { + // The edit replaced only the region the model saw. Refreshing the + // observation to complete would let a following full-file write publish + // content built from the slice alone. + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(reg.get(abs("doc.txt"))?.complete).toBe(false) + + await expect(guardedWrite(task, "doc.txt", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + + it("rejects a create-kind full-file overwrite of an existing file when only a partial read observed it", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedFsAccess.mockResolvedValue(undefined) // target still on disk + const task = createMockTask({ observationRegistry: reg }) + + // A "create" whose target exists publishes through the same + // full-file replacement path as "update": a partial observation must + // not authorize dropping the content the model never read. + await expect(guardedWrite(task, "doc.txt", "created", "create")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + }) + + it("allows a create-kind recreate of a vanished file despite a partial observation - a fresh create needs no prior read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) // target absent + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "created", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + }) + + it("publishes a create-kind overwrite of an existing file when the observation is complete", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) // target still on disk + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "created", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + }) + }) + + describe("observation refresh after publish (S4b review round)", () => { + it("refreshes the observation with the post-publish token so a consecutive edit does not fail stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The first publish moves the on-disk token: edit 1's pre-write CAS + // sees v1, the post-publish refresh sees v2, and edit 2's CAS sees v2. + mockedComputeVersionToken.mockResolvedValueOnce("v1").mockResolvedValueOnce("v2").mockResolvedValue("v2") + + await guardedWrite(task, "doc.txt", "first edit", "edit") + await guardedWrite(task, "doc.txt", "second edit", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("doc.txt"), "second edit") + // the observation now carries the post-publish token, complete + expect(reg.get(abs("doc.txt"))?.version).toBe("v2") + expect(reg.get(abs("doc.txt"))?.complete).toBe(true) + }) + + it("refreshes as a COMPLETE observation so a consecutive full-file update is not rejected partial", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // update 1 pre-write CAS + .mockResolvedValueOnce("v2") // update 1 post-publish refresh + .mockResolvedValue("v2") // update 2 pre-write CAS + + await guardedWrite(task, "doc.txt", "first", "update") + await guardedWrite(task, "doc.txt", "second", "update") + + // a refresh recorded as partial would have the second update + // rejected by the completeness gate + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("doc.txt"))?.complete).toBe(true) + }) + + it("keeps the previous observation when the post-publish token cannot be computed (deletion race after publish)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The publish succeeded but the file was deleted before the refresh + // stat: the token computation rejects (ENOENT) and the guard's + // .catch normalizes it to undefined. The observation must keep the + // pre-publish version (the next write fails closed through the + // standard deleted/stale path) rather than a token-less record. + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // edit 1 pre-write CAS + .mockRejectedValueOnce({ code: "ENOENT" }) // edit 1 post-publish refresh - file deleted + .mockResolvedValue("v1") // edit 2 pre-write CAS + + await guardedWrite(task, "doc.txt", "first edit", "edit") + await guardedWrite(task, "doc.txt", "second edit", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("doc.txt"))?.version).toBe("v1") + }) + }) + + describe("edit", () => { + it("fails read-first when the file was never observed - nothing published, no I/O", async () => { + const task = createMockTask() + + await expect(guardedWrite(task, "any.txt", "patched", "edit")).rejects.toThrow( + "File not read yet -- read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("publishes when the version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v3") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "patched", "edit")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v3); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("concurrency: per-path FIFO chain", () => { + it("two concurrent updates on one path - serialized, both publish against the refreshed observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("shared.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token), and the + // guarded write refreshes the observation to it, so the second + // write CASes against v2 and publishes too: same-task writes are + // serialized last-write-wins in submission order, while a token that + // moves outside the task's own publish still fails stale. + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + const p1 = guardedWrite(task, "shared.txt", "first", "update") + const p2 = guardedWrite(task, "shared.txt", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("shared.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("shared.txt"), "second") + }) + + it("observed-absent then two concurrent creates - the second publishes against the refreshed observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("absent.txt"), "v1") // read before, file later vanished + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask({ observationRegistry: reg }) + + let publishes = 0 + mockedSafeWriteText.mockImplementation(async () => { + publishes += 1 + if (publishes === 1) { + // After the first publish the file exists again under a new + // token, and the guarded write refreshes the observation to it. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + } + }) + + const p1 = guardedWrite(task, "absent.txt", "first", "create") + const p2 = guardedWrite(task, "absent.txt", "second", "create") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + // Both same-task creates serialize: the second CASes against the + // refreshed v2 observation and publishes its content last-write-wins. + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(publishes).toBe(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("absent.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("absent.txt"), "second") + }) + + it("the chain settles after a rejection - a later matching write still runs", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("settle.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") // already stale at v1 + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "settle.txt", "first", "update") + await expect(p1).rejects.toThrow("Stale version") + + // No resetChain: the rejected link must not wedge the chain. The + // caller re-reads the file (observation refreshed to v2) and retries. + reg.observe(abs("settle.txt"), "v2") + const p2 = guardedWrite(task, "settle.txt", "second", "update") + await expect(p2).resolves.toBeUndefined() + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second") + }) + + it("evicts settled chain entries - a later write still serializes in order", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("evict.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // A first write settles; its chain entry is evicted with it. + const p1 = guardedWrite(task, "evict.txt", "first", "update") + await expect(p1).resolves.toBeUndefined() + + // Two rapid writes submitted after the eviction must still run one + // at a time in submission order (the eviction must not drop the + // chain for in-flight or just-enqueued links). + const order: (string | Uint8Array)[] = [] + mockedSafeWriteText.mockImplementation(async (_path: string, content: string | Uint8Array) => { + order.push(content) + }) + const p2 = guardedWrite(task, "evict.txt", "second", "update") + const p3 = guardedWrite(task, "evict.txt", "third", "update") + await Promise.all([p2, p3]) + + expect(order).toEqual(["second", "third"]) + // Three publishes in total: the settled first write plus the two + // serialized rapid writes. + expect(mockedSafeWriteText).toHaveBeenCalledTimes(3) + }) + + it("writes on different paths are independent (no cross-path serialization)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1") + reg.observe(abs("b.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "a.txt", "a", "update") + const p2 = guardedWrite(task, "b.txt", "b", "update") + await Promise.all([p1, p2]) + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + }) + }) + + describe("path resolution", () => { + it("resolves a relative path against task.cwd", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("sub/dir.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "sub/dir.txt", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content") + }) + + it("normalizes an already-absolute input (trailing separator) to the observation key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("sub/dir.txt") + // ReadFileTool observes under path.resolve(task.cwd, relPath) — the + // canonical spelling. A write addressed with a trailing separator used + // to bypass the observation (isAbsolute passthrough) and fail + // "File already exists" / "File not read yet" for a file that was read. + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, canonical + "/", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content") + }) + + it("serializes two spellings of one file through a single chain key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("shared2.txt") + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token). + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + // Plain spelling vs the trailing-separator spelling: with one chain + // key they are strictly ordered. The first matches v1 and publishes; + // its post-publish refresh records v2, so the second CASes against + // v2 and publishes too (serialized same-task writes). + const p1 = guardedWrite(task, canonical, "first", "update") + const p2 = guardedWrite(task, canonical + "/", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, canonical, "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, canonical, "second") + }) + }) + + describe("lock serialization with other writers", () => { + it("holds the shared advisory lock across the version check and the publish", async () => { + // The guard decision and the publish must be one operation under the same + // advisory lock that safeWriteJson and task-history deletion use, otherwise + // a lock-using writer can land between the check and the write. + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedComputeVersionToken.mockImplementation(async () => { + order.push("check") + return "v1" + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + }) + + await replaceIfVersion(abs("a.txt"), "v1", "content", "a.txt") + + expect(order).toEqual(["lock", "check", "publish", "check", "release"]) + expect(mockedWithFileLock).toHaveBeenCalledWith(abs("a.txt"), expect.any(Function)) + }) + + it("holds the same lock across the absence check and the publish for an unobserved create", async () => { + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedFsAccess.mockImplementation(async () => { + order.push("check") + throw { code: "ENOENT" } + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + }) + + mockedComputeVersionToken.mockImplementation(async () => { + order.push("token") + return "v1" + }) + + await createIfAbsent(abs("new.txt"), "hello", "new.txt") + + expect(order).toEqual(["lock", "check", "publish", "token", "release"]) + }) + + it("locks the resolved publish target instead of the link path", async () => { + // proper-lockfile keys by the path it is given, so a symlink alias and its + // referent would take two locks for one file. The guard must lock the key + // every other writer to that file uses. + const referent = abs("real/file.txt") + mockedResolveLockKey.mockResolvedValue(referent) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + + await createIfAbsent(abs("link.txt"), "hello", "link.txt") + + expect(mockedResolveLockKey).toHaveBeenCalledWith(abs("link.txt")) + expect(mockedWithFileLock).toHaveBeenCalledWith(referent, expect.any(Function)) + }) + + it("reads the post-publish token inside the lock, before releasing it", async () => { + // A peer lock-using writer can publish in the gap between the publish and + // a post-publish stat made after the lock is released, and the task would + // then record that peer's token as its own observation. + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedComputeVersionToken.mockImplementation(async () => { + order.push("check") + return "v1" + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + return undefined + }) + + const token = await replaceIfVersion(abs("a.txt"), "v1", "content", "a.txt") + + expect(order).toEqual(["lock", "check", "publish", "check", "release"]) + expect(token).toBe("v1") + }) + }) + + describe("resetChain", () => { + it("detaches pending links so later writes start a fresh chain", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("x.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "x.txt", "a", "update") + resetChain() + await guardedWrite(task, "x.txt", "b", "update") + + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") + }) + }) + + it("does not run a queued write once the owning task is cancelled", async () => { + const task = createMockTask({ abort: true }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "new-file.txt", "hello", "create")).rejects.toThrow( + "Task was cancelled before this write ran -- the queued publish is not performed.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks cancellation under the publish lock before writing", async () => { + // The dequeue check passed; the abort landed while the lock was being taken, + // so the guard must refuse before the publish rather than write for a task that + // is already gone. + const task = createMockTask() + mockedWithFileLock.mockImplementation(function (filePath, operation) { + task.abort = true + return operation(path.resolve(filePath)) + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "new-file.txt", "hello", "create")).rejects.toThrow( + "Task was cancelled before this write published -- nothing was written.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks cancellation after the awaited preflight, before publication starts", async () => { + // The abort lands while the version token is being computed. The guard has to + // refuse on the way to the publish, not write for a task that is already gone. + mockedComputeVersionToken.mockImplementation(async () => { + task.abort = true + return "v1" + }) + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1", true) + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "a.txt", "content", "update")).rejects.toThrow( + "Task was cancelled before this write published -- nothing was written.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("names the caller's path, not the resolved absolute path, in a model-facing rejection", async () => { + // The guard key stays absolute, but the message and the error field go to the + // model, so they must not carry a user-specific absolute path. + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + let error: GuardRejectedError | undefined + await guardedWrite(task, "src/thing.ts", "hello", "create").catch((e: unknown) => { + if (e instanceof GuardRejectedError) { + error = e + return + } + throw e + }) + + expect(error?.message).toBe( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + expect(error?.path).toBe("src/thing.ts") + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("names the caller's path on a stale-version rejection as well", async () => { + // The stale branch is the most common rejection, so it has to follow the same rule + // as the create, delete and cancellation branches. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const reg = new ObservationRegistry() + reg.observe(abs("src/thing.ts"), "v1", true) + const task = createMockTask({ observationRegistry: reg }) + + let staleError: GuardRejectedError | undefined + await guardedWrite(task, "src/thing.ts", "hello", "update").catch((e: unknown) => { + if (e instanceof GuardRejectedError) { + staleError = e + return + } + throw e + }) + + expect(staleError?.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(staleError?.path).toBe("src/thing.ts") + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts new file mode 100644 index 0000000000..13d870308f --- /dev/null +++ b/src/core/tools/guardedWrite.ts @@ -0,0 +1,418 @@ +/** + * Guarded-write compare-and-swap core (upstream epic #1375, phase A4a). + * + * Wraps the S3 safeWriteText publish primitive behind version-token guards so + * that every write is deterministic: + * + * - an unobserved target may only be created when it is absent + * (createIfAbsent); + * - an observed target is published only when the on-disk version token still + * matches the current observation token (replaceIfVersion); + * - an edit-style write requires a prior observation (unobservedEditGuard). + * + * A per-absolute-path FIFO chain of tail promises orders concurrent + * in-process writes to the same path. Each publish refreshes the observation to + * the token it wrote when the new token can be computed, so same-task writes + * apply last-write-wins; when that refresh fails the observation keeps the + * previous observation token. A write that goes through replaceIfVersion fails + * stale when the on-disk token differs from the token the observation currently + * holds; an observed "create" whose target has disappeared instead uses + * createIfAbsent and can recreate it. Observations come from the task's S2 + * ObservationRegistry and authorize the write as well as the version check. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../utils/versionToken" +import { withFileLock } from "../../utils/fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import type { Task } from "../task/Task" + +// -- Types ------------------------------------------------------------------ + +/** Write kind that drives guard selection. */ +export type GuardedWriteKind = "create" | "update" | "edit" + +/** Error thrown when a guard rejects a write. Exported so a caller can tell a guard verdict from an unrelated failure. + */ +export class GuardRejectedError extends Error { + constructor( + message: string, + readonly path: string, + ) { + super(message) + this.name = "GuardRejectedError" + } +} + +// -- Per-path tail-promise chain -------------------------------------------- + +/** + * Per-absolute-path FIFO chain of pending guarded writes (tail promise per + * path). Every write enqueues onto the current tail for its path, so + * concurrent writes to the same path run one at a time in submission order. + * + * The chain never leaks a rejection through itself: each link settles, a + * rejected link is skipped by the next writer (a failed write must not block + * later writes to the same path), and every caller receives its own link + * promise to handle. + * + * Settled entries are evicted (below), so a long-lived extension does not + * accumulate a map entry per distinct written path. + */ +const pendingChains = new Map>() + +/** + * Enqueue a write operation on the per-path FIFO chain. + * + * Returns the promise for this link; it always settles. A prior link that + * rejected is skipped, not propagated. The map entry for this link is + * deleted once it settles — but only while it is still the current tail for + * the path, so a replacement enqueued in the meantime keeps ownership. + */ +function enqueue(pathKey: string, fn: () => Promise): Promise { + const prev = pendingChains.get(pathKey) ?? Promise.resolve() + const next = prev.then(fn, fn) + pendingChains.set(pathKey, next) + void next.then( + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + ) + return next +} + +// -- Guard primitives -------------------------------------------------------- + +/** + * Extract a Node errno code (e.g. "ENOENT") from a thrown value, or + * undefined when the value carries none. + */ +export function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined +} + +/** + * Cancellation re-check under the publish lock. A write queued on the FIFO + * chain can outlive its task: the caller already reported the write to the model, + * so a task aborted while its link waited must not publish afterwards. + */ +function cancelledBeforePublish(absolutePath: string, displayPath: string, isCancelled?: () => boolean): void { + if (isCancelled?.()) { + throw new GuardRejectedError( + "Task was cancelled before this write published -- nothing was written.", + displayPath, + ) + } +} + +/** True when the path is absent on disk (fs.access reports ENOENT). */ +async function fileIsAbsent(absolutePath: string): Promise { + try { + await fs.access(absolutePath) + return false + } catch (error: unknown) { + return errorCode(error) === "ENOENT" + } +} + +/** + * Publish content only if the target file does not exist. + * + * Rejects with a loud remediation error when the file already exists: the + * write was issued for a file that was never read, so the caller must read + * the file first, then retry. + */ +/** + * Read the on-disk token after a publish, best-effort: a publish that + * succeeded is not undone by a failed stat, so the caller keeps the publish + * and only skips the observation refresh. + */ +async function tokenAfterPublish(absolutePath: string): Promise { + return computeVersionToken(absolutePath).catch(() => undefined) +} + +export async function createIfAbsent( + absolutePath: string, + content: string | Uint8Array, + displayPath: string, + // Re-checked under the lock: a link that waited on the FIFO chain can outlive + // the task that queued it. + isCancelled?: () => boolean, +): Promise { + // Lock the key every other writer to this file uses: the resolved publish + // target, so a symlink alias and its referent share one lock. + return withFileLock(await resolveLockKey(absolutePath), async () => { + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + try { + await fs.access(absolutePath) + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") { + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + // Immediately before publication starts. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + await safeWriteText(absolutePath, content) + // Read the new token under the same lock, otherwise a peer lock-using + // writer can publish in the gap and the caller records that writer's + // token as its own observation. + return tokenAfterPublish(absolutePath) + } + + throw new GuardRejectedError( + "File already exists at " + + displayPath + + " and was not read before this write -- read the file first, then retry.", + displayPath, + ) + }) +} + +/** + * Publish content only if the current on-disk version token equals + * expectedVersion (the token observed at read time). + * + * On a match the content is published via the S3 safeWriteText primitive; on + * a mismatch the write is rejected stale with a re-read-then-retry + * remediation suffix. + * + * Atomicity boundary: the check and the publish run inside one acquisition of the + * shared advisory lock, and the post-publish token is read under that same lock, so + * no writer that participates in the protocol can interleave here. Every production + * caller of the publish primitive holds the same canonical key (safeWriteJson + * acquires it before publishing; TaskHistoryStore deletion takes it on the same + * resolved key). The primitive itself cannot re-acquire the lock -- withFileLock must + * not be re-entered inside an operation, and the callers already hold it, so wrapping + * it would deadlock them. A writer that never takes the lock is outside the supported + * threat model: a plain rename has no conditional form, so no advisory mechanism can + * bind a checked version to it. + */ +export async function replaceIfVersion( + absolutePath: string, + expectedVersion: string, + content: string | Uint8Array, + // Re-checked under the lock: a link that waited on the FIFO chain can outlive + // the task that queued it. + displayPath: string, + isCancelled?: () => boolean, +): Promise { + // Lock the key every other writer to this file uses: the resolved publish + // target, so a symlink alias and its referent share one lock. + return withFileLock(await resolveLockKey(absolutePath), async () => { + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + let currentVersion: string + try { + currentVersion = await computeVersionToken(absolutePath) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + // The observed file was deleted after the read: the version recorded + // at read time no longer exists on disk. Normalize the raw ENOENT + // into the guard's re-read-then-retry contract so the caller gets a + // remediation it can act on, not a raw errno. + throw new GuardRejectedError( + "File was deleted after it was read -- the version recorded at read time (" + + expectedVersion + + ") no longer exists; re-read the file, then retry.", + displayPath, + ) + } + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + + // Re-checked after the awaited preflight: the task can be aborted while this + // operation waits, and a publish that starts after that is a write the caller + // has already reported as not performed. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + + if (currentVersion === expectedVersion) { + // Immediately before publication starts. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + await safeWriteText(absolutePath, content) + // Read the new token under the same lock, otherwise a peer lock-using + // writer can publish in the gap and the caller records that writer's + // token as its own observation. + return tokenAfterPublish(absolutePath) + } + + throw new GuardRejectedError( + "Stale version -- the file changed since you read it (expected " + + expectedVersion + + ", current " + + currentVersion + + "); re-read the file, then retry.", + displayPath, + ) + }) +} + +/** + * Unobserved-edit guard: an edit-style write without a prior observation is + * rejected before any I/O. The literal-match / patch logic stays with the + * tools in S4b; this guard only verifies that a read happened first. + * + * Returns Promise because the rejection is total: this function + * never resolves. + */ +export async function unobservedEditGuard(absolutePath: string, displayPath: string): Promise { + throw new GuardRejectedError("File not read yet -- read the file, then retry.", displayPath) +} + +// -- Public API -------------------------------------------------------------- + +/** + * Resolve a relative or absolute path against task.cwd. + * + * path.resolve also normalizes an already-absolute input (collapsing "." / ".." + * segments and trailing separators), so the key always matches the + * ObservationRegistry key recorded at read time (ReadFileTool observes under + * path.resolve(task.cwd, relPath)) and two spellings of one file share one + * FIFO chain. + */ +function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { + return path.resolve(task.cwd, relPathOrAbsolute) +} + +/** + * Guarded write entry point. + * + * 1. Resolves the absolute path against task.cwd. + * 2. Consults the task's S2 observation registry to pick the guard: + * - unobserved + create/update: createIfAbsent (rejects if it exists); + * - observed + create on a file that vanished after the read: recreate; + * - observed otherwise: replaceIfVersion (CAS on the S1 version token); + * - unobserved + edit: unobservedEditGuard. + * 3. A full-file replacement ("update", or a "create" whose target still + * exists) additionally requires a complete observation: a partial read + * (slice, range, truncated, indentation block) authorizes targeted edits + * only, never a full-file overwrite of the existing content. + * 4. Runs the chosen guard on the per-path FIFO chain so concurrent writes to + * the same path are deterministically ordered. + * 5. After a successful publish, refreshes the observation with the new + * on-disk token (complete) so consecutive writes by the same task do not + * fail stale against the version they just published. + */ +export async function guardedWrite( + task: Task, + relPathOrAbsolute: string, + content: string | Uint8Array, + kind: GuardedWriteKind = "update", + // Optional completeness for the refresh. A tool that built its content from a + // view of another file must carry that view's completeness through the publish + // instead of claiming completeness for lines it never read. + completeOverride?: boolean, +): Promise { + const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) + // Model-facing path: the caller's own spelling, not the resolved absolute + // path. The guard key stays absolute, but a rejection must not put a + // user-specific absolute path into the model's context. + + return enqueue(absolutePath, async () => { + // Cancellation is checked when the link is dequeued, not when it was enqueued: + // a write queued before an abort can still reach its turn on the chain after the + // task is gone, and the caller has already reported the write to the model. + const displayPath = relPathOrAbsolute + + if (task.abort) { + throw new GuardRejectedError( + "Task was cancelled before this write ran -- the queued publish is not performed.", + displayPath, + ) + } + const obs = task.observationRegistry.get(absolutePath) + // A targeted edit authorizes only the view the model saw, so a partial + // observation stays partial; a full-file publish is complete. + let staysPartial = false + // Token the guard read under the lock, so the refresh records the token + // this write published rather than a peer writer's. + let publishedToken: string | undefined + + if (kind === "edit") { + // Edit-style writes require a prior read: no observation, no write. + // A targeted edit only authorizes the view the model saw, so a + // partial observation is valid for the edit itself; the version + // check still rejects a file that moved since the read. + if (obs === undefined) { + await unobservedEditGuard(absolutePath, displayPath) + } else { + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + ) + staysPartial = obs.complete === false + } + } else { + // "create" or "update" publish a full file built on the model's + // content. A replacement of an existing target -- an "update", or a + // "create" whose target is still on disk -- therefore requires a + // complete observation: a slice, range, truncated, or + // indentation-block read only authorizes the view the model saw, and + // publishing over the existing file would silently drop everything + // the model never read, so the guard fails closed with a + // re-read-the-whole-file remediation. A fresh create (absent target) + // needs no prior read and stays allowed. + const absent = kind === "create" && (await fileIsAbsent(absolutePath)) + if (!absent && obs !== undefined && obs.complete === false) { + throw new GuardRejectedError( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + displayPath, + ) + } + + if (obs === undefined) { + // Never read: only an absent target may be created. + publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort) + } else if (absent) { + // A "create" on a file that vanished after the read recreates it. + publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort) + } else { + // The version recorded at read time must still match the on-disk + // token. + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + ) + } + } + + // A publish changes the on-disk token (the rename changes ino, size, and + // mtime). The model just wrote the full content, so refresh the + // observation with the new token: a consecutive write by the same task + // must not fail stale against the version it just published. + // The guard already read the token under the lock; a failed stat after a + // successful publish only skips the refresh, it does not undo the publish. + if (publishedToken !== undefined) { + // Refresh with the new token, keeping the completeness the guard + // established: a partial observation that authorized a targeted edit + // must stay partial, otherwise a later full-file replacement would + // publish content built from the slice alone. + task.observationRegistry.observe(absolutePath, publishedToken, completeOverride ?? !staysPartial) + } + }) +} + +/** + * Reset the per-path tail-promise chains (test hook). + */ +export function resetChain(): void { + pendingChains.clear() +} From 45b791264cc492ea2c83a9d7292ba776c9dbead2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:12:08 +0800 Subject: [PATCH 12/42] rebuild unit U8 on U5 (corrected merge order) U6's ApplyPatchTool calls saveChanges with the writeKind argument, so the parameter must exist before U6 can build. U8 owns that signature, so U8 now lands before U6. --- src/integrations/editor/DiffViewProvider.ts | 486 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 2056 ++++++++++++++++- 2 files changed, 2360 insertions(+), 182 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..caa3ee1d26 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,6 +18,10 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" +import { versionTokenOfStat } from "../../utils/versionToken" +import { withFileLock } from "../../utils/fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import { guardedWrite, GuardRejectedError, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -39,6 +43,7 @@ export class DiffViewProvider { // it when re-showing the edited file afterward. private documentWasPinned = false private relPath?: string + private teardownInFlight: Promise | undefined private newContent?: string private activeDiffEditor?: vscode.TextEditor private fadedOverlayController?: DecorationController @@ -82,6 +87,14 @@ export class DiffViewProvider { viewColumn: vscode.ViewColumn }> = [] private taskRef: WeakRef + /** + * Version token of the empty placeholder open() wrote for a new file (the + * create branch), captured under the S2 stat-matched contract. A rejected + * guarded save removes the placeholder only while its on-disk token still + * equals this value, so a file created by someone else in the meantime is + * never unlinked. + */ + private placeholderVersion: string | undefined = undefined constructor( private cwd: string, @@ -120,7 +133,31 @@ export class DiffViewProvider { this.preDiagnostics = vscode.languages.getDiagnostics() if (fileExists) { + // S4b follow-up (#44 / epic #1375): the preview is a full read of the + // on-disk original. Observe it with the S2 stat-matched contract so the + // accepted save (a full-file replacement) publishes through the guard's + // version check instead of bypassing it; a stat mismatch (or failure) + // leaves the target unobserved and the save fails closed. + const preStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) this.originalContent = await fs.readFile(absolutePath, "utf-8") + const postStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const displayTask = this.taskRef.deref() + // Only record the preview token when the task has no observation for + // this path: a read_file observation recorded the version the model's + // content was built on, and the accept-time guard must compare against + // THAT token. Replacing it with the current on-disk token would blind + // the save to changes that happened between the model's read and this + // preview (e.g. an external editor), letting a v1-based overwrite + // clobber the v2 change. An unread target has no observation, so the + // preview token is recorded (stat-matched) but never as a complete read: + // this is the tool's own preview, not a read the model made, so it must + // not authorize a later full-file replacement. + if (displayTask && preStats && postStats && !displayTask.observationRegistry.has(absolutePath)) { + const displayToken = versionTokenOfStat(preStats) + if (displayToken === versionTokenOfStat(postStats)) { + displayTask.observationRegistry.observe(absolutePath, displayToken, false) + } + } } else { this.originalContent = "" } @@ -132,6 +169,46 @@ export class DiffViewProvider { // Make sure the file exists before we open it. if (!fileExists) { await fs.writeFile(absolutePath, "") + // S4b follow-up (#44 / epic #1375): the empty placeholder is fully + // known (empty), but verify it with the same S2 stat-matched contract + // as the modify branch above: a stat-mismatched or non-empty read + // means another writer touched the placeholder in the window after + // open() wrote it, and that writer's token must not be observed as + // complete (observing it would claim we saw content we never read, and + // the token-guarded cleanup would unlink their file) - nothing is + // recorded and the save fails closed instead. When the placeholder is + // verified empty, its token replaces any prior observation for the + // path: a prior observation describes a file that no longer exists, and + // keeping it would make the accept-time CAS (the placeholder token on + // disk vs. the vanished file's token) fail every time, so recreating + // the file would always fail. The placeholder token is the correct + // baseline for the new file: an external change to the placeholder + // before the accept moves the on-disk token and fails the CAS. The + // cleanup token is captured whether or not the task is still live: + // a rejected save must not leave the placeholder behind even when the + // owning task has been collected. + const placeholderPreStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (placeholderPreStats) { + const placeholderContent = await fs.readFile(absolutePath, "utf-8").catch(() => undefined) + const placeholderPostStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const placeholderToken = versionTokenOfStat(placeholderPreStats) + // Stat-matched (S2): the read is trusted only while the bracketing + // stats agree and the content is exactly the empty placeholder. + if ( + placeholderPostStats && + placeholderToken === versionTokenOfStat(placeholderPostStats) && + placeholderContent === "" + ) { + // Remember the placeholder token so a rejected save can remove + // the placeholder only while it is still the exact file open() + // wrote. + this.placeholderVersion = placeholderToken + const displayTask = this.taskRef.deref() + if (displayTask) { + displayTask.observationRegistry.observe(absolutePath, placeholderToken, true) + } + } + } } // If the file was already open, close it (must happen after showing the @@ -323,9 +400,81 @@ export class DiffViewProvider { } } + /** + * Revert one document through the workbench command. The command takes no + * resource argument: when the Open Editors view has focus with a selection it + * force-reverts every selected editor, otherwise the active editor. Activate + * the target first so only this document is reverted, then give the user + * their focus back. + */ + private async revertDocument(document: vscode.TextDocument): Promise { + const previous = vscode.window.activeTextEditor + try { + await vscode.window.showTextDocument(document, { preserveFocus: false, preview: false }) + await vscode.commands.executeCommand("workbench.action.files.revert") + } catch { + // best-effort: a document that cannot be reverted stays dirty + } + if (previous && previous.document !== document) { + // Give the user their focus back. + try { + await vscode.window.showTextDocument(previous.document, { preserveFocus: false, preview: false }) + } catch { + // best-effort: the focus cannot always be restored + } + } + // The caller must know whether the discard actually completed: a document + // that stays dirty can still be saved by VS Code's ordinary file service, + // which would recreate a placeholder the cleanup removed. + return !document.isDirty + } + + /** + * Adopt content that is already on disk as the result of this save. + * + * VS Code's autosave can write the modified side of a diff before the user + * accepts it, which moves the version token without changing the bytes, so + * the compare-and-swap rejects a guard that is already satisfied. The read is + * paired with the bigint stat that produced the token and re-checked after the + * read, so a write landing between the two cannot make an unrelated content + * match look like this publish. The observation keeps the completeness of the + * original read: a caller-side check must not upgrade a partial observation + * into authority for a full-file replacement. + * + * Returns true only when the on-disk bytes are exactly the content this save + * intended to publish. + */ + private async adoptAlreadyPublishedContent( + task: Task, + absolutePath: string, + encodedContent: Uint8Array, + ): Promise { + const before = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!before) { + return false + } + const disk = await fs.readFile(absolutePath).catch(() => undefined) + if (!disk) { + return false + } + const after = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!after || versionTokenOfStat(before) !== versionTokenOfStat(after)) { + return false + } + if (Buffer.compare(Buffer.from(disk), Buffer.from(encodedContent)) !== 0) { + return false + } + const observation = task.observationRegistry.get(absolutePath) + task.observationRegistry.observe(absolutePath, versionTokenOfStat(after), observation?.complete ?? false) + return true + } async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + // Stryker disable next-line StringLiteral: an empty kind dispatches exactly + // like "update" in guardedWrite (only "edit" and "create" branch distinctly), + // so the StringLiteral mutant here is equivalent. + writeKind: GuardedWriteKind = "update", ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -339,20 +488,156 @@ export class DiffViewProvider { const updatedDocument = this.activeDiffEditor.document const editedContent = updatedDocument.getText() - if (updatedDocument.isDirty) { - await updatedDocument.save() + // S4b follow-up (#44 / epic #1375): the accepted diff is a full-file + // replacement, so publish it through the guarded-write API instead of + // saving the document raw. open() observed the on-disk version the + // preview was built on; replaceIfVersion rejects the save when the file + // changed after the preview or the target was never observed, with the + // standard re-read-then-retry remediation. + const saveTask = this.taskRef.deref() + let encodedContent: Uint8Array | undefined + try { + if (!saveTask) { + // Fail closed: without the owning task the observation registry is + // unreachable and the save cannot be guarded. The rejection flows + // through the same discard-only cleanup as a guard verdict, so a + // dead task cannot leave the empty placeholder behind either. + throw new Error("Cannot guard the write: the owning task is no longer available") + } + // Publish with the document's own encoding. VS Code's codec covers the + // legacy code pages Node cannot represent (a hand-rolled encoder would + // have to reject them), so encode here and let the guarded write publish + // the bytes unchanged. The write kind comes from the caller: a targeted + // edit after a partial read is authorized by the edit guard, while a + // full-file replacement still needs a complete observation. + encodedContent = await vscode.workspace.encode(editedContent, { + encoding: updatedDocument.encoding, + }) + await guardedWrite(saveTask, this.relPath, encodedContent, writeKind) + } catch (error) { + // Autosave can publish the modified side of the diff before the user + // accepts, so the bytes on disk may already be exactly what this save + // intended. The compare-and-swap still rejects because the version token + // moved, and the cleanup below would report a failure for content that is + // already published. When a stat-matched read returns the same bytes the + // write already happened, so adopt that state instead of failing. + if ( + // Stryker disable next-line LogicalOperator: GuardRejectedError is only thrown + // by guardedWrite, and guardedWrite is reached only after the !saveTask check + // above has thrown a plain Error. So whenever this operand is evaluated saveTask + // is provably non-null: dropping it cannot change which branch is taken. + error instanceof GuardRejectedError && + saveTask && + encodedContent && + // Only the autosave shape: a clean buffer means its content is what autosave + // already put on disk. A dirty buffer means the disk content came from + // someone else, so the discard cleanup below is still the right outcome. + !updatedDocument.isDirty && + (await this.adoptAlreadyPublishedContent(saveTask, absolutePath, encodedContent)) + ) { + // The publish is already satisfied; fall through to the normal + // post-save flow rather than reporting a rejection. + } else { + // Discard-only failure cleanup. The publish was rejected (stale + // version, unobserved target, a partial-read observation, or an + // unavailable task), so the on-disk content is the newer source of + // truth. Reload it + // into the buffer (discarding the rejected edit), remove the empty + // new-file placeholder while it is still exactly the file open() + // wrote, and close the diff views. Never use revertChanges() here: + // it restores originalContent and saves it, which would overwrite + // the newer disk content that caused the rejection. Best-effort — + // the guard verdict is rethrown below. + try { + // Dispose before any programmatic activation: showTextDocument can + // change the active editor even with preserveFocus, so a listener + // still attached here would record this cleanup as a user touch and + // let the auto-close preferences keep the transient tab open. + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + + await this.runTeardown(async () => { + let discardSucceeded = !updatedDocument.isDirty + if (updatedDocument.isDirty) { + discardSucceeded = await this.revertDocument(updatedDocument) + } + if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { + // Cleanup has to be serialized with the same resolved-path advisory lock + // every other writer to this file uses. A token check and an unlink in + // separate steps let a peer writer commit in the gap and lose its write. + // A differing token, or a lock that cannot be taken, leaves the file in place. + await withFileLock(await resolveLockKey(absolutePath), async () => { + const placeholderStats = await fs + .stat(absolutePath, { bigint: true }) + .catch(() => undefined) + if ( + !placeholderStats || + versionTokenOfStat(placeholderStats) !== this.placeholderVersion + ) { + return + } + let unlinked = false + try { + await fs.unlink(absolutePath) + unlinked = true + } catch { + // the placeholder vanished or the unlink failed + } + if (!unlinked) { + return + } + // The file is gone, so its tab must go too: closing only the diff + // views would leave a clean plain-text tab for a deleted file. + await this.closeFileTab(absolutePath) + // The directories open() created for the new file must go with it, + // innermost first. rmdir refuses a directory another writer populated + // in the meantime, so the cleanup stops at the first failure. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch { + break + } + } + }) + } + await this.closeOwnDiffView(absolutePath) + }) + } catch { + // cleanup is best-effort; the guard verdict below is the outcome + } + throw error + } } + // The publish wrote the buffer's exact content to disk, but the + // document still carries its pre-save dirty flag and the close helpers + // skip dirty tabs. Revert from disk (content is identical — no write, + // no token change) to clear the dirty state before the close logic. + // document.save() would re-publish through the unguarded VS Code file + // service and advance the on-disk token, so the revert is the + // content-safe way to clear it. // Stop tracking touches and cancel any pending scroll-to-diff before any - // programmatic editor activation below. + // programmatic editor activation: showTextDocument below can change the + // active editor even with preserveFocus, so a listener still attached would + // record this programmatic revert as a user touch and keep the transient + // tab open against the auto-close preference. this.disposeActiveEditorListener() this.cancelDeferredScroll() + // Revert only while the buffer is still exactly what the guard published. + // Keystrokes typed during the publish would be discarded by a revert, so a + // buffer that moved on stays dirty: the close helpers skip dirty tabs and + // the user's text survives in the editor. + if (updatedDocument.isDirty && updatedDocument.getText() === editedContent) { + await this.revertDocument(updatedDocument) + } + await this.closeAllDiffViews() // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const saveTask = this.taskRef.deref() + // preserve the existing behavior when unset (saveTask was resolved above + // for the guarded publish). const saveState = await saveTask?.providerRef.deref()?.getState() await this.keepOrCloseEditedFile( @@ -527,55 +812,56 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - if (!fileExists) { - if (updatedDocument.isDirty) { - await updatedDocument.save() - } + await this.runTeardown(async () => { + if (!fileExists) { + if (updatedDocument.isDirty) { + await updatedDocument.save() + } - await this.closeAllDiffViews() - // The file was newly created for this edit; close its transiently - // opened tab before deleting it from disk. - await this.closeFileTab(absolutePath) - await fs.unlink(absolutePath) + await this.closeAllDiffViews() + // The file was newly created for this edit; close its transiently + // opened tab before deleting it from disk. + await this.closeFileTab(absolutePath) + await fs.unlink(absolutePath) - // Remove only the directories we created, in reverse order. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { - await fs.rmdir(this.createdDirs[i]) - } - } else { - // Revert document. - const edit = new vscode.WorkspaceEdit() + // Remove only the directories we created, in reverse order. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + await fs.rmdir(this.createdDirs[i]) + } + } else { + // Revert document. + const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - updatedDocument.positionAt(0), - updatedDocument.positionAt(updatedDocument.getText().length), - ) + const fullRange = new vscode.Range( + updatedDocument.positionAt(0), + updatedDocument.positionAt(updatedDocument.getText().length), + ) - edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) + edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) - // Apply the edit and save, since contents shouldn't have changed - // this won't show in local history unless of course the user made - // changes and saved during the edit. - await vscode.workspace.applyEdit(edit) - await updatedDocument.save() + // Apply the edit and save, since contents shouldn't have changed + // this won't show in local history unless of course the user made + // changes and saved during the edit. + await vscode.workspace.applyEdit(edit) + await updatedDocument.save() - await this.closeAllDiffViews() + await this.closeAllDiffViews() - // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const revertTask = this.taskRef.deref() - const revertState = await revertTask?.providerRef.deref()?.getState() - - await this.keepOrCloseEditedFile( - absolutePath, - false, - revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? - DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, - revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, - ) - } + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset. + const revertTask = this.taskRef.deref() + const revertState = await revertTask?.providerRef.deref()?.getState() + await this.keepOrCloseEditedFile( + absolutePath, + false, + revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) + } + }) // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. await this.restorePreviewTabs() @@ -618,6 +904,69 @@ export class DiffViewProvider { await Promise.all(closeOps) } + /** + * Close only this provider's diff tab. closeAllDiffViews() closes every clean + * diff tab in the workbench, so a rejected save would also close another task's + * diff view while that task's provider still holds its activation listener and + * deferred scroll timer against a tab that is gone. A rejection belongs to one + * task, so the cleanup must stay inside that task's view. + */ + private async closeOwnDiffView(absolutePath: string): Promise { + const target = path.resolve(absolutePath) + const tabs = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter((tab) => { + if (tab.isDirty) { + return false + } + if (tab.input instanceof vscode.TabInputTextDiff) { + // Only Zoo's own diff tabs, not a Source Control diff the user has open + // for the same file. + return ( + tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && + path.resolve(tab.input.modified.fsPath) === target + ) + } + // A diff tab for a file that was already open is identified by its label + // rather than by the URI scheme. A basename alone cannot tell two tasks in + // different directories apart, so the label only counts when the tab's own + // URI points at this provider's target. + const uri = (tab.input as { uri?: { fsPath?: string } })?.uri + return ( + typeof uri?.fsPath === "string" && + path.resolve(uri.fsPath) === target && + typeof tab.label === "string" && + tab.label.startsWith(`${path.basename(target)}: ${DIFF_VIEW_LABEL_CHANGES}`) + ) + }) + for (const tab of tabs) { + try { + await vscode.window.tabGroups.close(tab) + } catch { + // best-effort: the tab stays open + } + } + } + /** + * Only one teardown path may act on a document at a time. A cancellation can reach + * revertChanges() while a rejected save is already discarding the same buffer, and + * both would edit the document and close the same tabs. The second caller awaits the + * cleanup already in flight instead of repeating it. + */ + private async runTeardown(cleanup: () => Promise): Promise { + if (this.teardownInFlight !== undefined) { + await this.teardownInFlight + return + } + const inFlight = cleanup() + this.teardownInFlight = inFlight + try { + await inFlight + } finally { + this.teardownInFlight = undefined + } + } + // Stop tracking user activation of the target file. Called before any // programmatic showTextDocument so our own re-show never counts as a "touch". private disposeActiveEditorListener(): void { @@ -1108,7 +1457,15 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.closeAllDiffViews() + // A reset belongs to one task. Closing every clean diff tab would close another + // task's view while that task's provider still holds its activation listener and + // deferred scroll timer against a tab that is gone, so the cleanup stays inside + // this provider's view whenever it knows which file it was editing. + if (this.relPath) { + await this.closeOwnDiffView(path.resolve(this.cwd, this.relPath)) + } else { + await this.closeAllDiffViews() + } this.editType = undefined this.isEditing = false this.originalContent = undefined @@ -1127,6 +1484,7 @@ export class DiffViewProvider { this.userTouchedDocument = false this.userTouchedDiffEditor = false this.snapshotPreviewTabs = [] + this.placeholderVersion = undefined } /** @@ -1136,6 +1494,10 @@ export class DiffViewProvider { * @param relPath - Relative path to the file * @param content - Content to write to the file * @param openFile - Whether to show the file in editor (false = open in memory only for diagnostics) + * @param writeKind - Guarded-write kind that selects the S4a guard for this publish. + * Defaults to "create" because this method always publishes a complete file + * content: an unobserved target may only be created when absent, and an + * observed target must still carry the current observation token. * @returns Result of the save operation including any new problems detected */ async saveDirectly( @@ -1144,6 +1506,10 @@ export class DiffViewProvider { openFile: boolean = true, diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + writeKind: GuardedWriteKind = "create", + // Completeness the caller earned elsewhere; a move carries the source's + // view through the publish instead of claiming completeness for lines it never read. + completeOverride?: boolean, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -1154,9 +1520,19 @@ export class DiffViewProvider { // Get diagnostics before editing the file this.preDiagnostics = vscode.languages.getDiagnostics() - // Write the content directly to the file + // Publish through the S4 guarded-write API (epic #1375): an unobserved + // write to an existing file and a stale observed version are rejected + // with a re-read-then-retry remediation instead of overwriting the file. + // Concurrent in-process writes to the same path are already ordered by + // the guard's per-path FIFO chain, so no additional locking is added here. + const task = this.taskRef.deref() + if (!task) { + // Fail closed: without the owning task the observation registry is + // unreachable and the write cannot be guarded. + throw new Error("Cannot guard the write: the owning task is no longer available") + } await createDirectoriesForFile(absolutePath) - await fs.writeFile(absolutePath, content, "utf-8") + await guardedWrite(task, relPath, content, writeKind, completeOverride) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory @@ -1167,13 +1543,11 @@ export class DiffViewProvider { preserveFocus: true, }) } else { - // Just open the document in memory to trigger diagnostics without showing it - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) - - // Save the document to ensure VSCode recognizes it as saved and triggers diagnostics - if (doc.isDirty) { - await doc.save() - } + // Just open the document in memory to trigger diagnostics without showing it. + // Do not save here: the guarded publish already committed the accepted content, + // and saving a dirty buffer would republish its stale bytes through VS Code's + // unguarded save path, over what the guard wrote. + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) // Force a small delay to ensure diagnostics are triggered await new Promise((resolve) => setTimeout(resolve, 100)) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index aee88f4061..f1972efabd 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -5,6 +5,15 @@ import delay from "delay" import { makeRange, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import * as fs from "fs/promises" + +import { computeVersionToken, versionTokenOfStat } from "../../../utils/versionToken" +import type { BigIntStats } from "fs" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { withFileLock } from "../../../utils/fileLock" +import { ObservationRegistry } from "../../../core/task/observationRegistry" +import type { Task } from "../../../core/task/Task" + // Mock delay vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined), @@ -15,23 +24,67 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + // The S4b follow-up (#44) preview observation stats the target before and + // after reading it; undefined stats leave the target unobserved (fail closed). + stat: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +// Mock safeWriteText (used by saveDirectly) +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn().mockResolvedValue(undefined), + resolveLockKey: vi.fn(async (p: string) => p), })) +// Mock the S1 version token (used by the S4 guarded write); the real +// computeVersionToken needs fs.stat, which is not part of the fs/promises mock above. +// Keep the real versionTokenOfStat: DiffViewProvider.open() (S4b follow-up #44) +// derives the preview token from its synthetic stat mock with that pure function. +vi.mock("../../../utils/versionToken", async () => { + const actual = await vi.importActual("../../../utils/versionToken") + return { + computeVersionToken: vi.fn(), + versionTokenOfStat: actual.versionTokenOfStat, + } +}) + // Mock utils +// Mock the shared advisory lock that the guarded-write path uses; the real +// proper-lockfile would try to create a lock directory on the mocked fs. +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(async (filePath: string, operation: (p: string) => Promise) => operation(filePath)), +})) + vi.mock("../../../utils/fs", () => ({ createDirectoriesForFile: vi.fn().mockResolvedValue([]), })) // Mock path vi.mock("path", () => ({ - resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), + resolve: vi.fn((cwd: string, relPath?: string) => (relPath === undefined ? cwd : `${cwd}/${relPath}`)), + isAbsolute: vi.fn((p: string) => p.startsWith("/")), basename: vi.fn((path) => path.split("/").pop()), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), + join: (...args: string[]) => args.join("/"), })) // Mock vscode vi.mock("vscode", () => ({ workspace: { applyEdit: vi.fn(), + // VS Code's own codec. The double returns bytes that differ from the plain + // UTF-8 encoding of the same text, so an assertion can prove the publish + // writes the codec's output rather than re-encoding the string itself. + encode: vi.fn((content: string, options: { encoding: string }) => + Promise.resolve( + options.encoding === "utf8bom" + ? Buffer.concat([Buffer.from("\uFEFF"), Buffer.from(content)]) + : Buffer.from(content), + ), + ), onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), openTextDocument: vi.fn().mockResolvedValue({ isDirty: false, @@ -44,6 +97,7 @@ vi.mock("vscode", () => ({ }, window: { createTextEditorDecorationType: vi.fn(), + activeTextEditor: undefined as unknown, showTextDocument: vi.fn(), onDidChangeVisibleTextEditors: vi.fn(() => ({ dispose: vi.fn() })), onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), @@ -149,8 +203,11 @@ describe("DiffViewProvider", () => { return mockWorkspaceEdit as any }) - // Create a mock Task instance + // Create a mock Task instance. The guarded write (S4b) consults the task's + // S2 observation registry, so the mock carries a real (in-memory) instance. mockTask = { + cwd: mockCwd, + observationRegistry: new ObservationRegistry(), providerRef: { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ @@ -188,6 +245,15 @@ describe("DiffViewProvider", () => { addLines: vi.fn(), clear: vi.fn(), } + + // S4b follow-up (#44): saveChanges publishes the accepted diff through the + // guarded write, which requires the target to be observed at the previewed + // on-disk version. Seed the preconditions for the relPaths the suites below + // use; the guarded-write suites override or clear as needed. + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1") + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1") + mockTask.observationRegistry.observe(`${mockCwd}/mock-target-file.ts`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") }) describe("update method", () => { @@ -778,11 +844,188 @@ describe("DiffViewProvider", () => { }) }) + // A rejected save belongs to one task. closeAllDiffViews() closes every clean + // diff tab in the workbench, so it would close another task's diff view while + // that task's provider still holds its activation listener and deferred scroll + // timer against a tab that is gone. + describe("closeOwnDiffView method", () => { + it("closes only this provider's tab and leaves another task's diff view open", async () => { + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab]) + }) + + it("leaves another task's tab when the same basename sits in another directory", async () => { + // Two tasks can edit files with the same name. Matching by basename alone + // would close the other task's clean tab, so the tab's own URI has to decide. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const sameNameOtherDir = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: "/other-cwd/test.ts" }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + // A pre-opened file's tab is identified by its label, so the URI check is the + // only thing that can tell the two apart here. + const labelOnlyOtherDir = { + input: { uri: { fsPath: "/other-cwd/test.ts" } }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const labelOnlyOwn = { + input: { uri: { fsPath: `${mockCwd}/test.ts` } }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, sameNameOtherDir]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, sameNameOtherDir, labelOnlyOtherDir, labelOnlyOwn] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab, labelOnlyOwn]) + }) + + it("leaves a Source Control diff the user has open for the same file", async () => { + // A git diff of the same file is the user's tab, not this task's, so a reset + // must not close it. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const gitDiffTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: "git" }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, gitDiffTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, gitDiffTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab]) + }) + }) + + it("reset() closes only this provider's tab, not another task's", async () => { + // A guard rejection belongs to one task, and every tool caller resets that + // task's provider in its catch block, so the teardown must stay inside this + // provider's view. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + label: `other-task.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + diffViewProvider["relPath"] = "test.ts" + await diffViewProvider.reset() + + expect(closedTabs).toEqual([ownTab]) + }) + describe("saveDirectly method", () => { beforeEach(() => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + + // Baseline for the single-writer flow these tests encode: the file was read + // before the write, so the observation registry holds the version token the + // guarded write recomputes and compares, and the target exists on disk. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") + vi.mocked(fs.access).mockResolvedValue(undefined) }) it("should write content directly to file without opening diff view", async () => { @@ -791,9 +1034,9 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 2000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -814,14 +1057,32 @@ describe("DiffViewProvider", () => { it("should not open file when openWithoutFocus is false", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() }) + it("does not save a dirty buffer in the memory-only diagnostics path", async () => { + // The guarded publish already committed the accepted content. Saving a dirty + // buffer here would republish its stale bytes through VS Code's unguarded save + // path, over what the guard wrote. + const dirtyDoc = { + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.TextDocument + vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue(dirtyDoc) + + await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 0) + + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith( + expect.objectContaining({ fsPath: `${mockCwd}/test.ts` }), + ) + expect(dirtyDoc.save).not.toHaveBeenCalled() + }) + it("should skip diagnostics when diagnosticsEnabled is false", async () => { const mockDelay = vi.mocked(delay) mockDelay.mockClear() @@ -829,9 +1090,9 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() @@ -858,171 +1119,1677 @@ describe("DiffViewProvider", () => { expect((diffViewProvider as any).relPath).toBe("test.ts") expect((diffViewProvider as any).newContent).toBe("new content") }) - }) - - describe("saveChanges method with diagnostic settings", () => { - beforeEach(() => { - // Setup common mocks for saveChanges tests - ;(diffViewProvider as any).relPath = "test.ts" - ;(diffViewProvider as any).newContent = "new content" - ;(diffViewProvider as any).activeDiffEditor = { - document: { - getText: vi.fn().mockReturnValue("new content"), - isDirty: false, - save: vi.fn().mockResolvedValue(undefined), - }, - } - ;(diffViewProvider as any).preDiagnostics = [] - - // Mock vscode functions - vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) - vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) - }) - it("should apply diagnostic delay when diagnosticsEnabled is true", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + describe("guarded write (S4b, epic #1375)", () => { + const enoent = () => Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + it("rejects an unobserved write to an existing file with the read-first remediation", async () => { + mockTask.observationRegistry.clear() - const result = await diffViewProvider.saveChanges(true, 3000) + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "File already exists at test.ts and was not read before this write -- read the file first, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) - // Verify delay was called with correct duration - expect(mockDelay).toHaveBeenCalledWith(3000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + it("creates an unobserved file when the target is absent", async () => { + mockTask.observationRegistry.clear() + vi.mocked(fs.access).mockRejectedValue(enoent()) - it("should skip diagnostics when diagnosticsEnabled is false", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) - const result = await diffViewProvider.saveChanges(false, 2000) + it("rejects an observed write whose version token is stale", async () => { + // The file changed on disk after the read that recorded "v1". + vi.mocked(computeVersionToken).mockResolvedValue("v2") - // Verify delay was NOT called and diagnostics were NOT checked - expect(mockDelay).not.toHaveBeenCalled() - expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + const result = diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - it("should use default values when no parameters provided", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + await expect(result).rejects.toThrow("Stale version") + await expect(result).rejects.toThrow("re-read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + }) - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + it("rejects an unobserved edit-kind write before any I/O", async () => { + mockTask.observationRegistry.clear() - const result = await diffViewProvider.saveChanges() + await expect( + diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0, "edit"), + ).rejects.toThrow("File not read yet -- read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + expect(fs.access).not.toHaveBeenCalled() + }) - // Verify default behavior (enabled=true, delay=2000ms) - expect(mockDelay).toHaveBeenCalledWith(1000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + it("recreates an observed file that vanished after the read", async () => { + vi.mocked(fs.access).mockRejectedValue(enoent()) - it("should handle custom delay values", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) - const result = await diffViewProvider.saveChanges(true, 5000) + it("fails closed when the owning task has been collected", async () => { + // A real WeakRef cannot be forced to deref to undefined deterministically + // (GC timing), so a structural stub stands in for the collected reference. + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef - // Verify custom delay was used - expect(mockDelay).toHaveBeenCalledWith(5000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) }) }) - describe("preEditScrollLine capture and restore", () => { - it("should capture scroll line from visible editor at open() time", async () => { - const mockEditor = { + describe("saveChanges guarded publish (S4b follow-up #44)", () => { + // Synthetic stat the preview observation tokenizes with the real (unmocked) + // versionTokenOfStat. Cast: the mock only implements the members the tool + // and versionToken read. + const previewStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + + // Structural TextEditor double for the open()/saveChanges flow: only the + // members they touch. One documented unknown cast stands in for the full + // vscode.TextEditor type (avoids any casts; see AGENTS.md). + const mockTextEditor = (fsPath: string, text = ""): vscode.TextEditor => + ({ document: { - uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" }, - getText: vi.fn().mockReturnValue(""), + uri: { fsPath, scheme: "file" }, + getText: vi.fn().mockReturnValue(text), lineCount: 0, + encoding: "utf8", }, selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, edit: vi.fn().mockResolvedValue(true), revealRange: vi.fn(), - visibleRanges: [{ start: { line: 42 } }], - } + }) as unknown as vscode.TextEditor + + // Structural TextDocument double for the onDidOpenTextDocument callback. + const mockTextDocument = (fsPath: string): vscode.TextDocument => + ({ uri: { fsPath, scheme: "file" } }) as unknown as vscode.TextDocument + // The workbench revert clears the model's dirty flag; the double must model + // that so the cleanup can tell a completed discard from a failed one. + const revertClearsDirty = (document: { isDirty: boolean }, onRevert?: () => void): void => { + vi.mocked(vscode.commands.executeCommand).mockImplementation((command: string) => { + if (command === "workbench.action.files.revert") { + document.isDirty = false + onRevert?.() + } + return Promise.resolve(undefined) + }) + } - vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + beforeEach(() => { + // Private members are set via bracket notation (spec convention). + // Reset the focus the revert helper reads: a test that sets it must not leak + // into the next one, where cleanup could restore a stale editor. + vi.mocked(vscode.window).activeTextEditor = undefined + diffViewProvider["relPath"] = "test.ts" + diffViewProvider["newContent"] = "new content" + diffViewProvider["activeDiffEditor"] = mockTextEditor(`${mockCwd}/test.ts`, "new content") + diffViewProvider["preDiagnostics"] = [] + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockTextEditor(`${mockCwd}/test.ts`)) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + }) + + it("open() observes the previewed version of an existing file as a partial read, not a model read", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/observed.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { - setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" } } as any), 0) + setTimeout(() => callback(mockTextDocument(`${mockCwd}/observed.ts`)), 0) return { dispose: vi.fn() } }) - vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) - ;(diffViewProvider as any).editType = "modify" + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("observed.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/observed.ts`) + expect(obs).toBeDefined() + expect(obs!.version).toBe(versionTokenOfStat(previewStats)) + // The preview is the tool's own read, not a read the model made, so it must + // not claim completeness for content the model never saw. + expect(obs!.complete).toBe(false) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/observed.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/observed.ts`, { bigint: true }) + }) + + it("open() records no observation when the post-read stat rejects", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/observed.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/observed.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat) + .mockResolvedValueOnce(previewStats) + .mockRejectedValueOnce(Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("observed.ts") + + expect(mockTask.observationRegistry.has(`${mockCwd}/observed.ts`)).toBe(false) + // The accepted save is a full-file replacement, so an unobserved target still + // fails closed instead of publishing content built on a preview that could not + // be tied to a version token. + await expect( + diffViewProvider.saveDirectly("observed.ts", "new content", false, false, 0, "update"), + ).rejects.toThrow( + "File already exists at observed.ts and was not read before this write -- read the file first, then retry.", + ) + }) + it("open() observes the empty placeholder of a new file so the accepted save can be guarded", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/brand-new.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/brand-new.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("brand-new.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/brand-new.ts`) + expect(obs).toBeDefined() + expect(obs!.version).toBe(versionTokenOfStat(previewStats)) + expect(obs!.complete).toBe(true) + // The create path stat-matches the placeholder (pre + post read), so + // both calls carry the bigint requirement, and the verification read + // uses utf-8. + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/brand-new.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/brand-new.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + expect(vi.mocked(fs.readFile)).toHaveBeenCalledWith(`${mockCwd}/brand-new.ts`, "utf-8") + }) + + it("open() leaves the target unobserved when the pre/post stat mismatch (mid-preview mutation)", async () => { + const mutatedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(301), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/mutated.ts`)), 0) + return { dispose: vi.fn() } + }) + const mockEditor = mockTextEditor(`${mockCwd}/mutated.ts`) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(mutatedStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() - await diffViewProvider.open("scroll.ts") + await diffViewProvider.open("mutated.ts") - expect((diffViewProvider as any).preEditScrollLine).toBe(42) + expect(mockTask.observationRegistry.get(`${mockCwd}/mutated.ts`)).toBeUndefined() + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/mutated.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/mutated.ts`, { bigint: true }) }) - it("should set preEditScrollLine to undefined when the visible editor has no visibleRanges", async () => { - const mockEditorNoRanges = { - document: { - uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" }, - getText: vi.fn().mockReturnValue(""), - lineCount: 0, - }, - selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, - edit: vi.fn().mockResolvedValue(true), - revealRange: vi.fn(), - // No visibleRanges, so the capture in open() yields undefined. - } + it("open() leaves the target unobserved when the pre-read stat fails (stat gap)", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/gap.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/gap.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + // The pre-read stat fails and only the post-read stat resolves: the + // on-disk version the preview is built on is unproven, so open() must + // leave the target unobserved even though a post stat is available. + vi.mocked(fs.stat) + .mockRejectedValueOnce(new Error("EPERM: operation not permitted")) + .mockResolvedValueOnce(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() - vi.mocked(vscode.window).visibleTextEditors = [mockEditorNoRanges as any] - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditorNoRanges as any) + await diffViewProvider.open("gap.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/gap.ts`)).toBeUndefined() + }) + + it("open() leaves a new file unobserved when the placeholder stat fails", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/gap-create.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { - setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" } } as any), 0) + setTimeout(() => callback(mockTextDocument(`${mockCwd}/gap-create.ts`)), 0) return { dispose: vi.fn() } }) - vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) - ;(diffViewProvider as any).editType = "modify" + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockRejectedValue(new Error("EPERM: operation not permitted")) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() - await diffViewProvider.open("new.ts") + await diffViewProvider.open("gap-create.ts") - expect((diffViewProvider as any).preEditScrollLine).toBeUndefined() + expect(mockTask.observationRegistry.get(`${mockCwd}/gap-create.ts`)).toBeUndefined() }) - it("saveChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { - const mockRevealRange = vi.fn() - const mockSavedEditor = { revealRange: mockRevealRange } + it("publishes the accepted content through the guarded write (safeWriteText)", async () => { + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor as any) - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) - ;(diffViewProvider as any).documentWasOpen = true - ;(diffViewProvider as any).preEditScrollLine = 30 - // saveChanges early-exits without relPath, newContent, activeDiffEditor - ;(diffViewProvider as any).newContent = "content" - ;(diffViewProvider as any).activeDiffEditor = { + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(result.newProblemsMessage).toBe("") + }) + + it("publishes the accepted content in the document's own encoding", async () => { + // A utf8bom document: getText() returns the text without the BOM, so the + // publish must go through VS Code's codec for the document's own + // encoding rather than re-encoding the text as plain UTF-8. + const bomEditor = { document: { - getText: vi.fn().mockReturnValue("content"), - isDirty: false, - save: vi.fn().mockResolvedValue(undefined), + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8bom", }, - } + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = bomEditor await diffViewProvider.saveChanges(false) - expect(mockRevealRange).toHaveBeenCalledWith( - expect.objectContaining({ start: { line: 30, character: 0 } }), - vscode.TextEditorRevealType.AtTop, + expect(vi.mocked(vscode.workspace.encode)).toHaveBeenCalledWith("new content", { + encoding: "utf8bom", + }) + expect(safeWriteText).toHaveBeenCalledWith( + `${mockCwd}/test.ts`, + Buffer.concat([Buffer.from("\uFEFF"), Buffer.from("new content")]), ) }) - it("saveChanges() does NOT call revealRange when preEditScrollLine is undefined", async () => { - const mockRevealRange = vi.fn() - const mockSavedEditor = { revealRange: mockRevealRange } + it("rejects the accepted save when the file changed after the preview (stale version)", async () => { + vi.mocked(computeVersionToken).mockResolvedValue("v2") + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects the accepted save when the target was never observed (fail closed)", async () => { + mockTask.observationRegistry.clear() + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "File already exists at test.ts and was not read before this write -- read the file first, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects the accepted save when the observation was only a partial read", async () => { + // A partial observation (slice/range/truncated/indentation read) must not + // authorize the full-file replacement the accept path performs, even when + // the version is current. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1", false) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + it("publishes a targeted edit that a partial observation authorizes", async () => { + // The same partial observation rejects a full-file replacement but + // authorizes the targeted edit the tool performed, so the write kind + // the tool passed must reach the guard. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1", false) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await diffViewProvider.saveChanges(false, 0, "edit") + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + }) + + it("fails closed when the owning task has been collected", async () => { + // A real WeakRef cannot be forced to deref to undefined deterministically + // (GC timing), so a structural stub stands in for the collected reference. + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + + // Nothing may be published for a collected task, and the discard-only + // cleanup still runs before the error is rethrown. + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalledTimes(1) + }) + + it("open() keeps the model's existing observation instead of replacing it with the preview token", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/t3-modify.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-modify.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + // The model read the file before the preview: its observation must + // survive so the accept-time guard compares against the version the + // model's content was built on, not the on-disk version at preview + // time. + mockTask.observationRegistry.observe(`${mockCwd}/t3-modify.ts`, "model-token", true) + + await diffViewProvider.open("t3-modify.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/t3-modify.ts`) + expect(obs?.version).toBe("model-token") + expect(obs?.version).not.toBe(versionTokenOfStat(previewStats)) + // the stat-matched pair is still taken; only the observation is kept + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + }) + + it("open() records the placeholder token for a create even when the model read the file before it vanished", async () => { + // The vanished file's old observation must NOT win here: it describes + // a file that no longer exists, and keeping it would make the + // accept-time CAS (placeholder token on disk vs. the vanished file's + // token) fail every time, so recreating the file would always fail + // and the placeholder would leak. The placeholder token is the + // correct baseline for the new file. + const mockEditor = mockTextEditor(`${mockCwd}/t3-create.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-create.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + mockTask.observationRegistry.observe(`${mockCwd}/t3-create.ts`, "model-token", true) + + await diffViewProvider.open("t3-create.ts") + + const placeholderToken = versionTokenOfStat(previewStats) + // the placeholder is written, stat-matched, observed (replacing the + // vanished file's stale token), and remembered for cleanup + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "") + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + expect(mockTask.observationRegistry.get(`${mockCwd}/t3-create.ts`)?.version).toBe(placeholderToken) + expect(diffViewProvider["placeholderVersion"]).toBe(placeholderToken) + }) + + it("open() on a create with a collected task writes the placeholder but tracks nothing", async () => { + // The task has been collected (dead WeakRef): the placeholder is still + // written (the file must exist to open the diff), but there is no live + // task to observe - the later save fails closed through the taskRef + // fail-closed path. + const mockEditor = mockTextEditor(`${mockCwd}/t3-dead-task.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-dead-task.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await diffViewProvider.open("t3-dead-task.ts") + + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "") + // no live task: nothing observed, but the cleanup token is still + // captured (provider state) so the fail-closed save rejection can + // remove the placeholder instead of leaking it + expect(mockTask.observationRegistry.get(`${mockCwd}/t3-dead-task.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBe(versionTokenOfStat(previewStats)) + }) + + it("open() does not record a placeholder another writer touched after the write", async () => { + // CR d037753 finding: a writer that touched the placeholder between + // open()'s fs.writeFile() and the observation would have its token + // recorded as a complete observation of content open() never read. + // The stat-matched verification must reject it: no observation (the + // prior observation stays untouched), no cleanup token (so a rejected + // save cannot unlink the writer's file), and the save fails closed. + const mockEditor = mockTextEditor(`${mockCwd}/contested.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/contested.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("external content") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + mockTask.observationRegistry.observe(`${mockCwd}/contested.ts`, "model-token", true) + + await diffViewProvider.open("contested.ts") + + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/contested.ts`, "") + // nothing recorded, and the vanished file's prior observation was + // not replaced with the writer's token + expect(mockTask.observationRegistry.get(`${mockCwd}/contested.ts`)?.version).toBe("model-token") + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("open() does not record the placeholder when the post-stat fails after the write", async () => { + // The bracketing stats must both succeed: a failed post-stat means + // the read is not trustworthy, so nothing is recorded. + const mockEditor = mockTextEditor(`${mockCwd}/statfail.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/statfail.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat) + .mockResolvedValueOnce(previewStats) + .mockRejectedValueOnce(Object.assign(new Error("ENOENT: no such file"), { code: "ENOENT" })) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("statfail.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/statfail.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("open() does not record the placeholder when the bracketing stats disagree (mid-preview mutation)", async () => { + // A token change between the bracketing stats means the placeholder + // was replaced or rewritten while open() was reading it - same S2 + // rule as the modify branch: nothing is recorded. + const mutatedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(301), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const mockEditor = mockTextEditor(`${mockCwd}/mutated-new.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/mutated-new.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(mutatedStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("mutated-new.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/mutated-new.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("saveChanges() accepts a recreate after a prior read - the accept-time CAS checks the placeholder token", async () => { + // The exact recreate-always-failed trace: the model read the file + // (observed "v1" by the outer beforeEach), the file then vanished, + // open() wrote the placeholder, and the accept must succeed against + // the placeholder token (not the vanished file's stale token). + const mockEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/test.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + // prior read observation (the file has since vanished) + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe("v1") + + await diffViewProvider.open("test.ts") + + // the placeholder is untouched on disk: the accept-time token matches + // the placeholder token open() recorded + vi.mocked(computeVersionToken).mockResolvedValue(versionTokenOfStat(previewStats)) + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(result.newProblemsMessage).toBe("") + }) + + it("saveChanges() checks the placeholder token and unlinks inside the same lock", async () => { + // A peer writer that commits between the token check and the unlink would lose + // its write, so the cleanup runs under the same resolved-path advisory lock every + // other writer to this file uses. A token that moved inside the lock window means + // the file is no longer the placeholder and must be left in place. + const mockEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/test.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + + await diffViewProvider.open("test.ts") + + const lockKeys: string[] = [] + vi.mocked(withFileLock).mockImplementation(async (lockKey, operation) => { + lockKeys.push(lockKey) + if (lockKeys.length === 2) { + // the peer committed while the cleanup waited for the lock + vi.mocked(fs.stat).mockResolvedValue({ ...previewStats, size: 9999n, mtimeNs: 5n, ctimeNs: 6n }) + } + return operation(lockKey) + }) + + // The publish is rejected against the peer's token, so the discard cleanup runs. + vi.mocked(computeVersionToken).mockResolvedValue("peer-token") + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + /Stale version|not read before this write/, + ) + + // One acquisition for the guarded publish, one for the cleanup. + expect(lockKeys).toEqual([`${mockCwd}/test.ts`, `${mockCwd}/test.ts`]) + expect(vi.mocked(fs.unlink)).not.toHaveBeenCalled() + }) + + it("clears the dirty buffer via a disk revert after a successful guarded publish", async () => { + // The user edited the buffer before accepting, so the document is + // dirty: the publish wrote the exact buffer content, and the dirty + // flag must be cleared by reverting from disk rather than saving the + // buffer (which would republish through the unguarded file service). + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + // the revert activates the exact document with the exact options: + // preserveFocus keeps the user's focus, preview: false pins the tab + // the target is activated so the revert command is scoped to this document + // (no active editor to restore in this case) + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { + preserveFocus: false, + preview: false, + }) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(dirtyEditor.document.save).not.toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("discards the dirty buffer and removes the new-file placeholder after a guarded rejection, then rethrows", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + // open() wrote and observed the empty placeholder; the on-disk token + // then moved past it, so the guard rejects the publish. + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(safeWriteText).not.toHaveBeenCalled() + // discard-only cleanup: the newer disk content is reloaded into the + // buffer (never re-saved from originalContent), and the placeholder + // is unlinked while it is still exactly the file open() wrote + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { + preserveFocus: false, + preview: false, + }) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + // the placeholder stat uses the bigint stat options (the version + // token requires the full-precision fields) + // The read must be attributed to the bigint stat that produced the token: + expect(vi.mocked(fs.stat).mock.calls[0][1]).toEqual({ bigint: true }) + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("disposes the active-editor listener before the programmatic revert", async () => { + // showTextDocument can change the active editor even with + // preserveFocus, so the listener must be gone before the revert + // activates the document: otherwise this programmatic activation is + // recorded as a user touch and the auto-close preference is overridden. + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider["disposeActiveEditorListener"] = vi.fn(() => { + order.push("dispose") + }) + revertClearsDirty(dirtyEditor.document, () => order.push("revert")) + + await diffViewProvider.saveChanges(false) + + expect(order).toEqual(["dispose", "revert"]) + }) + + it("disposes the listener before the discard revert and closes the deleted file's tab after a successful unlink", async () => { + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["disposeActiveEditorListener"] = vi.fn(() => { + order.push("dispose") + }) + diffViewProvider["cancelDeferredScroll"] = vi.fn(() => { + order.push("cancel") + }) + diffViewProvider["closeFileTab"] = vi.fn().mockImplementation(() => { + order.push("closeTab") + return Promise.resolve() + }) + revertClearsDirty(dirtyEditor.document, () => order.push("revert")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // The tab for the file that was just unlinked is closed, and only + // after the revert; closing only the diff views would leave a clean + // plain-text tab for a deleted file behind. + expect(order).toEqual(["dispose", "cancel", "revert", "closeTab"]) + }) + + it("does not close the file tab when the placeholder unlink fails", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EACCES: permission denied, unlink")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // Nothing was deleted, so there is no deleted-file tab to close. + expect(closeFileTab).not.toHaveBeenCalled() + }) + + it("removes the directories open() created, innermost first, after the placeholder unlink", async () => { + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["createdDirs"] = [`${mockCwd}/new`, `${mockCwd}/new/dir`] + diffViewProvider["closeFileTab"] = vi.fn().mockImplementation(() => { + order.push("closeTab") + return Promise.resolve() + }) + vi.mocked(fs.rmdir).mockImplementation(async (p) => { + order.push("rmdir:" + p) + }) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // The empty directories open() made for the rejected new file go with the + // placeholder, innermost first, and only after the unlink succeeded. + expect(order).toEqual(["closeTab", "rmdir:" + `${mockCwd}/new/dir`, "rmdir:" + `${mockCwd}/new`]) + }) + + it("stops removing created directories when one cannot be removed", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["createdDirs"] = [`${mockCwd}/a`, `${mockCwd}/a/b`, `${mockCwd}/a/b/c`] + vi.mocked(fs.rmdir).mockRejectedValueOnce(new Error("ENOTEMPTY: directory not empty")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // A directory another writer populated in the meantime must not be removed, + // and the best-effort cleanup must still finish so the guard verdict stays + // the outcome. + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/a/b/c`) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("does not revert a buffer the user changed during the publish", async () => { + // The publish captured the buffer text before awaiting the write; a keystroke + // typed during that wait is newer than the published bytes, so the buffer must + // stay dirty instead of being reverted to disk. + const getText = vi.fn() + getText.mockReturnValueOnce("new content").mockReturnValueOnce("new content typed during the publish") + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText, + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(vi.mocked(vscode.commands.executeCommand)).not.toHaveBeenCalledWith("workbench.action.files.revert") + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + }) + + it("activates the document before the revert so the command is scoped to it, then restores the focus", async () => { + // workbench.action.files.revert takes no resource argument: with the Open + // Editors view focused it force-reverts every selected editor, otherwise the + // active editor. Activating the target keeps the revert scoped to this + // document, and the user's previous focus is given back afterwards. + const previousEditor = mockTextEditor(`${mockCwd}/other.ts`, "other") + vi.mocked(vscode.window).activeTextEditor = previousEditor + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + await diffViewProvider.saveChanges(false) + + const calls = vi.mocked(vscode.window.showTextDocument).mock.calls + expect(calls[0]).toEqual([dirtyEditor.document, { preserveFocus: false, preview: false }]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(calls[1]).toEqual([previousEditor.document, { preserveFocus: false, preview: false }]) + }) + + it("does not restore focus when the user had no active editor", async () => { + vi.mocked(vscode.window).activeTextEditor = undefined + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + await diffViewProvider.saveChanges(false) + + // Only the activation happened; there was no focus to give back. + expect(vi.mocked(vscode.window.showTextDocument).mock.calls).toEqual([ + [dirtyEditor.document, { preserveFocus: false, preview: false }], + ]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + }) + + it("does not restore focus when the active editor is already the target document", async () => { + // The user was already looking at this document, so there is nothing to give + // back: re-showing it would be a redundant activation. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + vi.mocked(vscode.window).activeTextEditor = editor + diffViewProvider["activeDiffEditor"] = editor + revertClearsDirty(editor.document) + + await diffViewProvider.saveChanges(false) + + // Only the activation happened: the focus was already on the target, so there + // was nothing to give back. + expect(vi.mocked(vscode.window.showTextDocument).mock.calls).toEqual([ + [editor.document, { preserveFocus: false, preview: false }], + ]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + }) + it("keeps the placeholder when the discard fails, so a later save cannot recreate the rejected content", async () => { + // The revert command can fail (a locked or orphaned model). While the buffer + // is still dirty, VS Code's ordinary file service can save it back to the path, + // so the placeholder open() wrote must survive and the tab must stay open. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + vi.mocked(vscode.commands.executeCommand).mockRejectedValue(new Error("revert failed")) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(fs.unlink).not.toHaveBeenCalled() + expect(closeFileTab).not.toHaveBeenCalled() + }) + + it("does not reload a clean buffer when the guard rejects - only dirty buffers are discarded", async () => { + // The buffer was never touched (isDirty is falsy): there is nothing + // to discard, so the failure cleanup must not activate the document + // or run the revert command. + const cleanEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + diffViewProvider["activeDiffEditor"] = cleanEditor + vi.mocked(computeVersionToken).mockResolvedValue("v2") + // The placeholder on disk is still exactly what open() wrote, so the cleanup + // can still remove it. + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(safeWriteText).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + expect(vi.mocked(vscode.commands.executeCommand)).not.toHaveBeenCalled() + // A clean buffer has nothing that can be saved back, so the placeholder + // cleanup still runs even though no revert was needed. + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("does not unlink the placeholder when the edit type is not create - the outer gate short-circuits", async () => { + // placeholderVersion is remembered (open() took the placeholder path) + // but the edit type is not create: the outer gate must short-circuit + // before statting or unlinking, so the placeholder on disk is left + // untouched. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "modify" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) // placeholder still on disk + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.unlink).not.toHaveBeenCalled() + // the dirty discard and the view close still ran + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("adopts content autosave already published instead of reporting a stale rejection", async () => { + // Autosave wrote the buffer before acceptance: the document is clean and the + // disk already holds exactly the bytes this save intended, but the version + // token moved, so the compare-and-swap still rejects. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + // A partial observation must stay partial: adopting content that is already on + // disk cannot upgrade it into authority for a full-file replacement. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), false) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + + // The observation now points at the state that already matches, keeping the + // completeness of the original read. + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.version).toBe(versionTokenOfStat(previewStats)) + expect(observation?.complete).toBe(false) + // Nothing was clobbered, so the buffer is not reloaded and no placeholder is + // unlinked; the normal post-save close flow still ran. + expect(fs.unlink).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) + + it("still rejects when the disk content does not match what the save intended", async () => { + // Same autosave shape, different bytes: the guard verdict stands. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("someone else") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + // The observation is left at the state the read saw, not upgraded to the + // autosaved content the save did not author. + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + // The rejection still stands: the buffer is discarded and the views close, + // so the caller sees the guard verdict, not a silent success. + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + it("keeps the rejection when the buffer is still dirty even though the disk matches", async () => { + // A dirty buffer means the disk content came from someone else, so the + // discard cleanup is still the outcome even when the bytes happen to match. + // Without the clean-document gate this test would adopt the match and skip + // the discard. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + // The clean-document gate short-circuits before the adoption check, so the + // rejection path never stats or reads the file. + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("keeps the rejection when the file moved between the stat and the read", async () => { + // Same bytes, but the second stat differs: the read is not attributable to + // the state the token describes, so a match cannot be adopted. + const movedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(9), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(movedStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + }) + + it("keeps a complete observation complete when adopting an autosaved match", async () => { + // The completeness of the original read must survive the adoption unchanged: + // a complete read stays complete, so the fallback default must not silently + // downgrade it. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.version).toBe(versionTokenOfStat(previewStats)) + expect(observation?.complete).toBe(true) + // The stat that pairs with the read must be the bigint form, otherwise the + // token is built from truncated fields. + expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) + }) + + it("keeps the rejection when the stat paired with the read is unavailable", async () => { + // There is no version to attribute the read to, so a match cannot be adopted. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockRejectedValueOnce(new Error("stat failed")) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + }) + + it("keeps the rejection when the paired read fails", async () => { + // The bytes cannot be compared, so a match cannot be assumed. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockRejectedValueOnce(new Error("read failed")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + }) + + it("records an incomplete observation when the path was never observed", async () => { + // Nothing was read in full, so the adopted state must be recorded as an + // incomplete observation rather than dereferencing a missing entry. A path the + // registry has never seen keeps the case independent of earlier tests. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/never-observed.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + diffViewProvider["relPath"] = "never-observed.ts" + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + const freshObservation = mockTask.observationRegistry.get(`${mockCwd}/never-observed.ts`) + expect(freshObservation?.version).toBe(versionTokenOfStat(previewStats)) + expect(freshObservation?.complete).toBe(false) + }) + it("keeps a non-guard write failure a failure even when the disk already matches", async () => { + // The guard passed and the write itself failed. The disk happens to hold the + // same bytes, but this save did not publish them, so the failure must not be + // reinterpreted as a success. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue(versionTokenOfStat(previewStats)) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockRejectedValueOnce(new Error("EACCES: permission denied")) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("EACCES: permission denied") + // No adoption read, so the guard verdict is the outcome. + expect(fs.readFile).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + it("does not adopt content when the failure is not a guard verdict", async () => { + // The bytes match and the buffer is clean, but the failure is the dead-task + // error, not a guard rejection: adoption must not turn an unrelated failure + // into a success. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + // The adoption check never runs, so the file is neither stat-ed nor read. + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("pairs the read with the bigint form on both sides of the comparison", async () => { + // A non-bigint stat truncates the fields the version token is built from, so + // both stats around the read must request the bigint form. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + expect(vi.mocked(fs.stat).mock.calls.map((call) => call[1])).toEqual([{ bigint: true }, { bigint: true }]) + }) + it("does not unlink when the placeholder stat is unavailable and still closes the diff views", async () => { + // The placeholder vanished between open() and the rejected save: the + // stat guard must short-circuit BEFORE the token comparison (no + // unlink) and the best-effort cleanup must not skip the view close. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + // the placeholder vanished: stat rejects and the guard's .catch + // normalizes it to undefined stats + vi.mocked(fs.stat).mockRejectedValue(new Error("ENOENT: no such file or directory")) + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.unlink).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("does not unlink a placeholder whose content changed after open() - the token gate refuses", async () => { + // The placeholder is still on disk, but its stats no longer match the + // token open() recorded: another writer touched the file, so the + // cleanup must refuse to unlink (it would destroy the other + // writer's content). + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + // the on-disk placeholder moved on since open(): same identity, new + // size -> a different token than the one open() recorded + const movedStats = { ...previewStats, size: BigInt(301) } as unknown as BigIntStats + vi.mocked(fs.stat).mockResolvedValue(movedStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) + // token mismatch -> the placeholder is NOT ours anymore: no unlink + expect(fs.unlink).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + }) + + describe("saveChanges method with diagnostic settings", () => { + beforeEach(() => { + // Setup common mocks for saveChanges tests + ;(diffViewProvider as any).relPath = "test.ts" + ;(diffViewProvider as any).newContent = "new content" + ;(diffViewProvider as any).activeDiffEditor = { + document: { + getText: vi.fn().mockReturnValue("new content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + } + ;(diffViewProvider as any).preDiagnostics = [] + + // Mock vscode functions + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + }) + + it("should apply diagnostic delay when diagnosticsEnabled is true", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges(true, 3000) + + // Verify delay was called with correct duration + expect(mockDelay).toHaveBeenCalledWith(3000) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("should skip diagnostics when diagnosticsEnabled is false", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges(false, 2000) + + // Verify delay was NOT called and diagnostics were NOT checked + expect(mockDelay).not.toHaveBeenCalled() + expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("should use default values when no parameters provided", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges() + + // Verify default behavior (enabled=true, delay=2000ms) + expect(mockDelay).toHaveBeenCalledWith(1000) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("should handle custom delay values", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges(true, 5000) + + // Verify custom delay was used + expect(mockDelay).toHaveBeenCalledWith(5000) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + }) + }) + + describe("preEditScrollLine capture and restore", () => { + it("should capture scroll line from visible editor at open() time", async () => { + const mockEditor = { + document: { + uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + visibleRanges: [{ start: { line: 42 } }], + } + + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("scroll.ts") + + expect((diffViewProvider as any).preEditScrollLine).toBe(42) + }) + + it("should set preEditScrollLine to undefined when the visible editor has no visibleRanges", async () => { + const mockEditorNoRanges = { + document: { + uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + // No visibleRanges, so the capture in open() yields undefined. + } + + vi.mocked(vscode.window).visibleTextEditors = [mockEditorNoRanges as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditorNoRanges as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("new.ts") + + expect((diffViewProvider as any).preEditScrollLine).toBeUndefined() + }) + + it("saveChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { + const mockRevealRange = vi.fn() + const mockSavedEditor = { revealRange: mockRevealRange } + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor as any) + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + ;(diffViewProvider as any).documentWasOpen = true + ;(diffViewProvider as any).preEditScrollLine = 30 + // saveChanges early-exits without relPath, newContent, activeDiffEditor + ;(diffViewProvider as any).newContent = "content" + ;(diffViewProvider as any).activeDiffEditor = { + document: { + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + } + + await diffViewProvider.saveChanges(false) + + expect(mockRevealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 30, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + }) + + it("saveChanges() does NOT call revealRange when preEditScrollLine is undefined", async () => { + const mockRevealRange = vi.fn() + const mockSavedEditor = { revealRange: mockRevealRange } vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor as any) ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) @@ -1147,6 +2914,34 @@ describe("DiffViewProvider", () => { expect(vscode.window.showTextDocument).not.toHaveBeenCalled() }) + it("revertChanges() does not run a second teardown while one is already in flight", async () => { + // Cancellation can reach revertChanges() while a rejected save is still + // discarding the same buffer. Both paths acting on the document and the same + // tabs is duplicate cleanup, so the second caller waits for the first. + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + const editor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + diffViewProvider["activeDiffEditor"] = editor + + const first = diffViewProvider.revertChanges() + const second = diffViewProvider.revertChanges() + await Promise.all([first, second]) + + expect(applyEdit).toHaveBeenCalledTimes(1) + }) + it("saveChanges() keeps the file open when the user touched it", async () => { const closeFileTab = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) @@ -1664,6 +3459,11 @@ describe("DiffViewProvider", () => { const setupProvider = (stateOverrides: Record = {}) => { const task = { + cwd: mockCwd, + // S4b follow-up (#44): saveChanges publishes through the guarded write, + // which resolves the path against task.cwd and consults the task's + // observation registry — both must be present on this suite's mock task. + observationRegistry: new ObservationRegistry(), providerRef: { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ @@ -1675,6 +3475,10 @@ describe("DiffViewProvider", () => { }, } const provider = new DiffViewProvider(mockCwd, task as any) + // The preview observation + matching version token let the guarded save + // proceed so these tests stay focused on the auto-close decision table. + task.observationRegistry.observe(`${mockTargetPath}`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") ;(provider as any).relPath = "auto-close-test.ts" ;(provider as any).newContent = "content" ;(provider as any).activeDiffEditor = buildActiveDiffEditor() From 22b6decf195e9880c96d798ef0b17e1c04d1a594 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:12:11 +0800 Subject: [PATCH 13/42] rebuild unit u6 in the corrected order --- src/core/tools/ApplyPatchTool.ts | 105 +++- .../__tests__/applyPatchTool.execute.spec.ts | 586 +++++++++++++++++- 2 files changed, 682 insertions(+), 9 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 56b2bf8909..4043faa3cf 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -11,6 +11,8 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { versionTokenOfStat } from "../../utils/versionToken" +import { GuardRejectedError, errorCode } from "./guardedWrite" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -85,10 +87,30 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } - // Process each hunk + // Process each hunk. The read doubles as the S2 observation for the + // guarded publish (ReadFileTool contract: stat before and after the + // read, observe only when the on-disk version is unchanged between the + // two stats). Without it, the in-place modify publish is an unobserved + // write and the composed chat-diff default rejects it ("File already + // exists ... and was not read before this write") even though this tool + // just read the exact content the patch was applied to. const readFile = async (filePath: string): Promise => { const absolutePath = path.resolve(task.cwd, filePath) - return await fs.readFile(absolutePath, "utf8") + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const content: string = await fs.readFile(absolutePath, "utf8") + const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + // The tool's own hunk read, not a model read: keep the completeness the + // model earned, and only on the version it was earned on. A file the model + // never read, or a version that moved since, stays partial. + const prior = task.observationRegistry.get(absolutePath) + const complete = prior?.complete === true && prior.version === preReadToken + task.observationRegistry.observe(absolutePath, preReadToken, complete) + } + } + return content } let changes: ApplyPatchFileChange[] @@ -214,9 +236,20 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, true, diagnosticsEnabled, writeDelayMs) + // Guarded publish: the patch supplies the complete new content, so create-guard + // semantics apply (an unobserved existing target is rejected, not overwritten). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + true, + diagnosticsEnabled, + writeDelayMs, + "create", + ) } else { - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + // The add path publishes a whole new file, so create-guard semantics + // apply here as well. + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "create") } // Track file edit operation @@ -408,12 +441,59 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save new content to the new path if (isPreventFocusDisruptionEnabled) { + // The destination content is the source file plus one targeted hunk, so it can + // only be as complete as the view the model had of the source. Carry that + // completeness to the destination BEFORE the guarded publish: the guard decides + // completeness at publish time, so a partial view must already be recorded when + // the per-path chain runs. Downgrading only after saveDirectly leaves a window + // in which a concurrent writer sees the destination as complete for content the + // model never fully read. + const sourceObs = task.observationRegistry.get(absolutePath) + const sourceComplete = sourceObs !== undefined && sourceObs.complete === true + if (!sourceComplete) { + // Only carry completeness the model already had for the destination. An + // unobserved destination stays unobserved so the guard's "read before a + // full-file write" rule still applies; recording a partial observation here + // would hand the model authority to edit a file it never read. + const destObs = task.observationRegistry.get(moveAbsolutePath) + if (destObs !== undefined) { + let destAbsent = false + try { + await fs.access(moveAbsolutePath) + } catch (error: unknown) { + // Only an access error that means "not there" is an absence verdict. An + // ELOOP or EACCES is a real I/O failure: reading it as absent would mark + // the destination partial and let the publish run against a path the tool + // never actually observed. + if (errorCode(error) !== "ENOENT") throw error + destAbsent = true + } + if (!destAbsent) { + // Replacing an existing destination with content built from a partial + // source view must be re-authorized by reading the source in full. Reject + // before any state changes and name the source: a remediation that names + // only the destination sends the model to re-read the wrong file, and a + // downgrade that survives a rejected publish loses a full destination read. + throw new GuardRejectedError( + `Cannot move a partially read file onto ${change.movePath}: re-read the whole source (${change.path}) first, then retry.`, + change.movePath, + ) + } + // The destination was read and then deleted: the create guard permits the + // publish, and the content is still only as complete as the view the model + // had of the source, so carry the source's completeness instead of the + // destination's stale one. + task.observationRegistry.observe(moveAbsolutePath, destObs.version, false) + } + } await task.diffViewProvider.saveDirectly( change.movePath, newContent, false, diagnosticsEnabled, writeDelayMs, + "create", + sourceComplete, ) } else { // Write to new path and delete old file @@ -433,9 +513,22 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } else { // Save changes to the same file if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Guarded publish: an update hunk is a targeted change, so it is guarded + // as an edit -- a partial observation authorizes it and stays partial. A + // stale observed version is still rejected with the re-read hint. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + ) } else { - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + // The diff-view save is the same targeted hunk as the guarded save above: + // it must select the same edit guard, otherwise a partial read is + // rejected and the approved patch is thrown away. + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 72ffb112bc..4684ea59fe 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -4,14 +4,57 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import path from "path" +import * as fsPromises from "fs/promises" import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { guardedWrite } from "../guardedWrite" import { ApplyPatchTool } from "../ApplyPatchTool" -vi.mock("fs/promises", () => ({ - default: { +// The vi.mock factory exposes the fs/promises functions under a `default` +// property (matching the SUT's default import), which the static module type +// does not declare; cast once at this boundary rather than at each call site. +const mockedFsPromises = vi.mocked( + fsPromises as unknown as { + default: { + access: MockedFunction + stat: ReturnType + unlink: MockedFunction + } + }, +) + +vi.mock("fs/promises", () => { + // The SUT imports the module two ways: applyPatchTool uses the default + // export, safeWriteText uses the namespace. Both must see the same doubles. + const doubles = { + // The target exists on disk, so the completeness gate applies to the + // follow-up guarded write in the partial-observation test. + access: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue("original file content\n"), + // Stable on-disk version for the S2 self-read observation (the hunk + // read now stats before and after; equal tokens record the observe). + stat: vi.fn().mockResolvedValue({ + dev: 7n, + ino: 4242n, + size: 1234n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + }), unlink: vi.fn().mockResolvedValue(undefined), - }, + // resolveLockKey canonicalizes the lock key, so the guard needs these + // even when no symlink is involved. + lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }), + readlink: vi.fn().mockRejectedValue(new Error("not a symbolic link")), + realpath: vi.fn(async (p: string) => String(p)), + } + return { default: doubles, ...doubles } +}) + +// Mock the shared advisory lock that the guarded-write path uses; the real +// proper-lockfile would try to create a lock directory on the mocked fs. +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(async (filePath: string, operation: (p: string) => Promise) => operation(filePath)), })) vi.mock("../../../utils/fs", () => ({ @@ -38,6 +81,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { | "say" | "processQueuedMessages" | "didEditFile" + | "observationRegistry" > let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> @@ -52,6 +96,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), recordToolUsage: vi.fn(), recordToolError: vi.fn(), rooIgnoreController: { @@ -94,3 +139,538 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolError).not.toHaveBeenCalled() }) }) + +describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyPatchTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + | "observationRegistry" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockSaveChanges: MockedFunction<(...args: unknown[]) => Promise> + let mockGetState: MockedFunction<() => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + const updatePatch = `*** Begin Patch +*** Update File: src/thing.ts +@@ +-original file content ++modified file content +*** End Patch` + + const addPatch = `*** Begin Patch +*** Add File: src/new.ts ++new line one ++new line two +*** End Patch` + + const movePatch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/new.ts +@@ +-original file content ++modified file content +*** End Patch` + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockSaveChanges = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockGetState = vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + saveChanges: mockSaveChanges, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: mockGetState, + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyPatchTool() + }) + + it("update: publishes the targeted hunk through the guarded saveDirectly with edit kind", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("update: keeps a partial observation partial so a later full-file write is still rejected", async () => { + // The hunk read is the tool's own read, not a model read. Upgrading the + // model's partial view to complete here would let a later write_to_file + // replace the file with content built from the slice the model saw. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999999", false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(false) + // The targeted hunk itself is still allowed, guarded as an edit. + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + + await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + + it("update: keeps a complete observation complete across the tool's own hunk read", async () => { + // Completeness is the model's, not the tool's: a complete read stays complete + // and a partial one stays partial, so a later full-file write is still gated. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(true) + }) + + it("update: does not carry completeness across a version the model never read", async () => { + // The model earned completeness on a different version than the one the patch + // helper read: the intervening change was never seen, so a later full-file + // replacement must still fail closed. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999998", true) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(false) + + await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + + it("update: observes the hunk read so the guarded publish is not unobserved", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The hunk read doubles as the S2 observation (ReadFileTool contract): + // stable pre/post stats record the version token, so the in-place modify + // publish is not rejected as an unobserved write. + const observed = mockTask.observationRegistry.get(path.resolve("/workspace/project", "src/thing.ts")) + expect(observed?.version).toBe("7:4242:1234:1700000000123456789:1700000000789999999") + }) + + it("update: does not observe when the pre- and post-read tokens disagree", async () => { + // The file changed mid-read: pre/post stats differ, so no observation is + // recorded and the guarded publish surfaces the unobserved-existing + // remediation instead of publishing against a stale version. + const statMock = mockedFsPromises.default.stat + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 9999n, mtimeNs: 3n, ctimeNs: 4n }) + + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + // The rejection value is controlled here, so assert the exact error that + // reached handleError instead of any call: a stat or parse failure would + // otherwise satisfy a bare toHaveBeenCalled(). + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + }) + + it("update: does not observe when the post-read stat rejects", async () => { + // The pre-read stat succeeds and the read itself succeeds, but the post-read + // stat fails, so the read cannot be tied to a version token and no + // observation is recorded. The guarded publish then fails closed against the + // existing, unobserved file rather than publishing content built from an + // unverifiable read. + const statMock = mockedFsPromises.default.stat + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + statMock.mockRejectedValueOnce(new Error("EACCES: permission denied")) + + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + }) + it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { + mockedFileExistsAtPath.mockResolvedValueOnce(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "new line one\nnew line two\n", + true, + true, + 1000, + "create", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: publishes the destination through the guarded saveDirectly with create kind", async () => { + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + false, + ) + }) + + it("move: rejects a partial source onto an observed destination before changing any state", async () => { + // The destination content is the source file plus one hunk, so it can only be + // as complete as the view the model had of the source. Rejecting before the + // registry is modified keeps a full destination read intact, and the message + // must name the source, because re-reading the destination cannot restore the + // source's completeness and would loop. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Nothing was downgraded: the destination keeps the completeness the model earned. + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ + message: + "Cannot move a partially read file onto src/new.ts: re-read the whole source (src/old.ts) first, then retry.", + }), + ) + }) + + it("move: carries the source's completeness when the observed destination was deleted", async () => { + // The destination was read and then deleted, so its observation is stale but the + // create guard permits the publish. Rejecting here would refuse a move onto an + // absent path; the content is still only as complete as the source view, so the + // source's completeness is carried instead of the destination's stale one. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + const completeAtPublish: Array = [] + mockSaveDirectly.mockImplementationOnce(async () => { + completeAtPublish.push(reg.get(destKey)?.complete) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(completeAtPublish).toEqual([false]) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: propagates a non-ENOENT destination access error before changing the observation", async () => { + // An ELOOP is not an absence verdict. Reading it as "the destination is gone" + // would mark the observation partial and let the publish run against a path the + // tool never actually observed. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ELOOP: too many symbolic links"), { code: "ELOOP" }), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ message: "ELOOP: too many symbolic links" }), + ) + }) + + it("move: a complete source read keeps the destination complete", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockSaveDirectly.mockImplementationOnce(async () => { + reg.observe(destKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: the destination cannot claim completeness when the source was never observed", async () => { + // The hunk read records no observation when its stat fails, so the model has + // no authority over the source content the move carried over. Nothing is + // recorded for the destination before the write chain runs, so the guard still + // refuses a full-file write to a file the model never read. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + mockedFsPromises.default.stat.mockImplementationOnce(() => Promise.reject(new Error("stat failed"))) + const observedAtPublish: boolean[] = [] + mockSaveDirectly.mockImplementationOnce(async () => { + observedAtPublish.push(reg.has(destKey)) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(path.resolve("/workspace/project", "src/old.ts"))).toBeUndefined() + expect(observedAtPublish).toEqual([false]) + await expect(guardedWrite(mockTask as Task, "src/new.ts", "full replacement", "create")).rejects.toThrow( + "File already exists at " + + "src/new.ts" + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: leaves an unobserved destination alone when the source read was partial", async () => { + // The publish records an observation only when it can compute the new on-disk + // token. With nothing recorded for the destination there is nothing to + // downgrade, and the carry must not dereference a missing observation. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe( + path.resolve("/workspace/project", "src/old.ts"), + "7:4242:1234:1700000000123456789:1700000000789999999", + false, + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(destKey)).toBeUndefined() + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("update: surfaces the unobserved-existing remediation as a tool error", async () => { + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) + + it("update: surfaces the stale-version remediation as a tool error", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + + it("update: the diff-view save selects the same edit guard as the guarded save", async () => { + // With focus-disruption prevention off the tool saves through the diff view. + // A partial read must not be rejected there, otherwise the patch the user + // approved is thrown away. + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("add: the diff-view save uses the create guard for a new file", async () => { + mockedFileExistsAtPath.mockResolvedValueOnce(false) + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create") + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) +}) From 42f5877fd61094ac11dd97c3d45f2953ab433da1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 23:42:02 +0800 Subject: [PATCH 14/42] fix(apply-patch): a full hunk read is a complete observation The carry rule only applies when the model already observed the file. With no prior observation there is nothing to carry, and the hunk read returned the whole content, so the observation is complete. Recording it as partial made the guard reject a file the tool had just read in full - the four apply_diff extension-host tests timed out on that rejection. 21 tests pass, tsc clean, ESLint --max-warnings=0 clean. --- src/core/tools/ApplyPatchTool.ts | 12 ++++++--- .../__tests__/applyPatchTool.execute.spec.ts | 25 ++++++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 4043faa3cf..5bf980c98d 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -102,11 +102,15 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - // The tool's own hunk read, not a model read: keep the completeness the - // model earned, and only on the version it was earned on. A file the model - // never read, or a version that moved since, stays partial. + // The tool's own hunk read, not a model read. When the model already observed the + // file, keep the completeness it earned and only on the version it was earned on; a + // partial view stays partial. With no prior observation this read returned the whole + // content, so the observation is complete. const prior = task.observationRegistry.get(absolutePath) - const complete = prior?.complete === true && prior.version === preReadToken + // Nothing to carry when the model never observed the file: this read returned the + // whole content, so it is a complete observation. Carry only when a prior observation + // exists and still describes the version that was read. + const complete = prior === undefined ? true : prior.complete === true && prior.version === preReadToken task.observationRegistry.observe(absolutePath, preReadToken, complete) } } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 4684ea59fe..9d4b23bcdd 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -47,6 +47,8 @@ vi.mock("fs/promises", () => { lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }), readlink: vi.fn().mockRejectedValue(new Error("not a symbolic link")), realpath: vi.fn(async (p: string) => String(p)), + // safeWriteText creates the backup directory before publishing. + mkdir: vi.fn().mockResolvedValue(undefined), } return { default: doubles, ...doubles } }) @@ -322,6 +324,25 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(reg.get(key)?.complete).toBe(true) }) + it("update: a hunk read with no prior observation records a complete observation", async () => { + // Nothing was earned before, so there is nothing to carry. The hunk read returned + // the whole file, which is a complete read, and the guarded publish must not be + // rejected for a file the tool itself read in full. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(reg.get(key)?.complete).toBe(true) + // The guard reads the same entry, so a complete read here is what lets a later + // full-file publish through. + expect(reg.has(key)).toBe(true) + }) + it("update: does not carry completeness across a version the model never read", async () => { // The model earned completeness on a different version than the one the patch // helper read: the intervening change was never seen, so a later full-file @@ -437,6 +458,8 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) + // The source had no prior observation and the hunk read returned the whole file, + // so the destination publish is a complete-content publish. expect(mockSaveDirectly).toHaveBeenCalledWith( "src/new.ts", "modified file content\n", @@ -444,7 +467,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "create", - false, + true, ) }) From aa0d182d9a4ccf24928f635593070591e1b1dd4b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 00:02:05 +0800 Subject: [PATCH 15/42] chore(ci): rerun the e2e-mock lane The apply_diff extension-host run returned 404 No fixture matched on the first request, which is the mock server, not the guard: the same code passes e2e at U7 and U9, and the guard path was verified locally (fresh hunk read records a complete observation and the publish succeeds). From b158e34870543fef7187bdee3a40cdf136ae6cb8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 02:49:16 +0800 Subject: [PATCH 16/42] fix(file-safety): inherit unit 1 committed guard This unit was rebuilt from the pre-fix content source, so its copy of safeWriteText.ts still restored the backup over a write whose commit rename had already succeeded when the parent-directory fsync failed. Unit 1 (fws/u1-atomic-publish) already fixes this. Taking the file here keeps the shared code byte-identical across the units, so merging the chain in order does not overwrite unit 1's fix. Tests: 52 passed in safeWriteText.spec.ts. --- src/services/file-safety/safeWriteText.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e378cbbae8..2aafd0cce0 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -167,11 +167,7 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu */ export async function resolvePublishTarget(absoluteFilePath: string): Promise { return fs.realpath(absoluteFilePath).catch(async (error: unknown) => { - const code = - typeof error === "object" && error !== null && "code" in error - ? (error as { code?: string }).code - : undefined - if (code !== "ENOENT") throw error + if (errorCode(error) !== "ENOENT") throw error // ENOENT also covers a dangling symlink, which must never be written through. // Only a lstat that also reports the path as absent may fall back to the // given path; a real lstat failure (EACCES, EIO) says nothing about whether @@ -289,6 +285,10 @@ export async function safeWriteText( let backupPath: string | null = null let releaseBackupOnSuccess = false + // Set once the commit rename has published the new content. After that point the + // backup is no longer a safe restore source: rolling it back would overwrite + // content the caller can already observe at the target path. + let committed = false // Non-null only when the win32 step-2 block saved a successful DACL dump: // it gates the step-5 restore and is tracked for the cleanup unlinks. let daclDumpPath: string | null = null @@ -397,16 +397,13 @@ export async function safeWriteText( await fs.rename(targetPath, backupPath) releaseBackupOnSuccess = true } catch (err: unknown) { - const code = - typeof err === "object" && err !== null && "code" in err - ? (err as { code?: string }).code - : undefined - if (code !== "ENOENT") throw err + if (errorCode(err) !== "ENOENT") throw err } } // -- Step 4: atomic rename temp -> target --------------------- await fs.rename(tempPath, targetPath) + committed = true // -- Step 4b (POSIX): fsync the parent directory so the directory entry // changed by the commit rename is durable, not just the file content. @@ -463,7 +460,10 @@ export async function safeWriteText( await fs.rmdir(stagingDir).catch(() => {}) } } catch (originalError: unknown) { - if (backupPath && releaseBackupOnSuccess) { + // Only a pre-commit failure can restore the backup. Once the commit rename + // published, a later failure (for example the post-commit directory fsync) + // must not overwrite the published content with the old file. + if (backupPath && releaseBackupOnSuccess && !committed) { try { await fs.rename(backupPath, targetPath) } catch (rollbackError: unknown) { From 5a0dd5de1bf2784d9d70223e51711cace9a2c555 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:04:46 +0800 Subject: [PATCH 17/42] test: rerun mocked e2e - no source change, previous run failed in the runner From 3fab60cce15cc7a67bb2bfc29b44e4a7171015f9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:44:29 +0800 Subject: [PATCH 18/42] fix(file-safety): keep the publish error message in RollbackFailureError safeWriteJson rethrows RollbackFailureError and the telemetry callers record only error.message, so the generic wrapper message lost the filesystem errno text of the publish failure. Include the publish error message while keeping the rollback context (publishError, rollbackError, backupPath) unchanged. Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0. --- src/services/file-safety/safeWriteText.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 2aafd0cce0..85c6a071ba 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -47,7 +47,7 @@ export class RollbackFailureError extends Error { constructor(publishError: unknown, rollbackError: unknown, backupPath: string) { super( - "Publish failed and the backup could not be restored to its original path -- the content is preserved at the backup location reported on this error.", + `Publish failed (${publishError instanceof Error ? publishError.message : String(publishError)}) and the backup could not be restored to its original path -- the content is preserved at the backup location reported on this error.`, { cause: publishError }, ) this.name = "RollbackFailureError" From c8c0d45bdb3fd416a72af3216c3a18ebb92bf55b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:45:49 +0800 Subject: [PATCH 19/42] test(file-safety): assert the exact staging directory removed after a rollback failure The assertion accepted any rmdir argument, so a regression that removed a different directory still passed. Read this write's own staging directory from fsSync.mkdirSync and assert that exact path. Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0. --- .../__tests__/safeWriteText.spec.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 007ff4d3c5..99fd6291fb 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -348,6 +348,26 @@ describe("safeWriteText", () => { // temp-shaped is unlinked afterwards expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) + + it("a failed post-commit directory fsync does not roll the backup back over the published content", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const dirPath = path.dirname(targetPath) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // The file fd opens normally; the parent-directory open after the commit + // rename fails, which is the post-commit durability failure. + vi.mocked(fsSync.openSync).mockImplementation((target) => { + if (String(target) === dirPath) throw new Error("EBADF") + return 1 + }) + + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow(PostCommitDurabilityError) + + // The commit rename already published the new content, so the backup must + // not be renamed back over it: only target->backup and temp->target run. + expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(2) + }) }) // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── @@ -546,6 +566,28 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) }) + it("win32 DACL save runs before the backup rename, not after it", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // The title is about order, so assert the order the mocks were actually + // called in. If the save ran after the backup rename the target would + // already be gone and the dump would describe the wrong file. + await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) + + const callOrder = vi.mocked(execFile).mock.invocationCallOrder + const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder + const saveCall = callOrder[0] + const restoreCall = callOrder[1] + const backupRename = renameOrder[0] + const commitRename = renameOrder[1] + + expect(saveCall).toBeLessThan(backupRename) + expect(backupRename).toBeLessThan(commitRename) + expect(commitRename).toBeLessThan(restoreCall) + }) + it("win32 DACL: dump is unlinked even when restore fails", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -1049,7 +1091,9 @@ describe("cleanup before a rollback failure is reported", () => { // The backup is what the caller can still recover, so it stays on disk; the // staging file and this write's own directory must not leak alongside it. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) - expect(fs.rmdir).toHaveBeenCalled() + const stagingDirs = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0])) + expect(stagingDirs.length).toBe(1) + expect(fs.rmdir).toHaveBeenCalledWith(stagingDirs[0]) const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[2] const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] From 72f4ded805336aeaaba34825362aef13bc680f11 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:49:12 +0800 Subject: [PATCH 20/42] fix(tools): the tool's own hunk read must not grant full-file authority processAllHunks reads the whole file for its own hunk matching, but the model only ever saw the patch context lines. Recording complete: true there handed a later guardedWrite(..., "update") the authority to publish a full-file replacement built from content the model never observed, which is exactly what the completeness flag exists to prevent. Record a partial observation when no prior observation exists; the targeted patch itself still passes because the "edit" guard accepts a partial observation. Local note: applyPatchTool.execute.spec.ts cannot run in this worktree (the 'diff' package is not resolvable from either node_modules here) and ESLint cannot resolve its config here; CI covers both. --- src/core/tools/ApplyPatchTool.ts | 2 +- .../__tests__/applyPatchTool.execute.spec.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 5bf980c98d..c3003729b1 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -110,7 +110,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Nothing to carry when the model never observed the file: this read returned the // whole content, so it is a complete observation. Carry only when a prior observation // exists and still describes the version that was read. - const complete = prior === undefined ? true : prior.complete === true && prior.version === preReadToken + const complete = prior === undefined ? false : prior.complete === true && prior.version === preReadToken task.observationRegistry.observe(absolutePath, preReadToken, complete) } } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 9d4b23bcdd..fcbd9510cc 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -324,10 +324,11 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(reg.get(key)?.complete).toBe(true) }) - it("update: a hunk read with no prior observation records a complete observation", async () => { - // Nothing was earned before, so there is nothing to carry. The hunk read returned - // the whole file, which is a complete read, and the guarded publish must not be - // rejected for a file the tool itself read in full. + it("update: a hunk read with no prior observation records a partial observation", async () => { + // Nothing was earned before, so there is nothing to carry. The tool read the whole + // file for its own hunk matching, but the model only ever saw the patch context, so + // this read cannot grant authority for a later full-file replacement. The targeted + // patch itself is still allowed: the "edit" guard accepts a partial observation. const key = path.resolve("/workspace/project", "src/thing.ts") const reg = mockTask.observationRegistry @@ -337,9 +338,9 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - expect(reg.get(key)?.complete).toBe(true) - // The guard reads the same entry, so a complete read here is what lets a later - // full-file publish through. + expect(reg.get(key)?.complete).toBe(false) + // The guard reads the same entry: a partial observation authorizes targeted edits on + // the view the model saw, but not a full-file replacement. expect(reg.has(key)).toBe(true) }) From 4bc7997b5a6a4f8750d2cc9c39775ad8d7adcac8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 05:51:11 +0800 Subject: [PATCH 21/42] test: re-run mocked e2e and the ubuntu lane - no source change since the last green run on these files From cb0b01b7bd163c5be42a65344a761ebd0ef71318 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 06:09:39 +0800 Subject: [PATCH 22/42] test(tools): the move test must follow the partial observation the hunk read now records The previous commit made a hunk read with no prior observation record a partial observation. The move test still asserted the completeness flag as true, so the ubuntu lane failed on test:coverage:core. The destination is still published through the create kind; only the completeness flag changes, and it is now false because the model never saw the whole source. Local note: this spec cannot run in this worktree (the 'diff' package is not resolvable from either node_modules here); CI covers it. --- src/core/tools/__tests__/applyPatchTool.execute.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index fcbd9510cc..0aa6ed1dc6 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -459,8 +459,9 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { pushToolResult: mockPushToolResult, }) - // The source had no prior observation and the hunk read returned the whole file, - // so the destination publish is a complete-content publish. + // The source had no prior observation, so the hunk read records a partial + // observation: the model only saw the patch context, not the whole source. The + // destination is still published (create kind), but the completeness flag stays false. expect(mockSaveDirectly).toHaveBeenCalledWith( "src/new.ts", "modified file content\n", @@ -468,7 +469,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { true, 1000, "create", - true, + false, ) }) From 3085fd5bf4e5009c4a6b5533cc9660fec151006d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 06:28:58 +0800 Subject: [PATCH 23/42] test: re-run mocked e2e - the same suite passed on the neighbouring units of this chain with identical guard behaviour From 9970d1d643600fc5795d378263c4bfef283bbdc5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 06:47:29 +0800 Subject: [PATCH 24/42] fix(file-safety): give the Windows DACL dump a per-write name The dump path was the fixed sibling .acl.tmp. A pre-existing user file at that path is unlinked by the failed-save branch and by both cleanup paths, and two concurrent writes to the same target share one dump, so one write can restore or delete the other's. Use the per-write unique name like the staging file. Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0. --- .../file-safety/__tests__/safeWriteText.spec.ts | 12 ++++++------ src/services/file-safety/safeWriteText.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 99fd6291fb..8b76906dda 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -170,7 +170,7 @@ describe("safeWriteText", () => { // through the default icacls path (options?.execFileRunner must // not throw when options is undefined) expect(vi.mocked(execFile)).toHaveBeenCalledTimes(2) - expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) } }) @@ -535,7 +535,7 @@ describe("safeWriteText", () => { const saveArgs = vi.mocked(execFile).mock.calls[0]?.[1] expect(saveArgs?.[1]).toBe("/save") // the dump path (possibly partially created by icacls) was unlinked - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { @@ -551,7 +551,7 @@ describe("safeWriteText", () => { // First call: save DACL from target before backup rename const firstCall = vi.mocked(execFile).mock.calls[0] expect(firstCall[0]).toBe("icacls") - expect(firstCall[1]).toEqual([targetPath, "/save", expect.stringContaining(".acl.tmp"), "/T"]) + expect(firstCall[1]).toEqual([targetPath, "/save", expect.stringContaining("safeWriteText.acl"), "/T"]) // Second call: restore DACL onto directory after commit rename const secondCall = vi.mocked(execFile).mock.calls[1] @@ -559,11 +559,11 @@ describe("safeWriteText", () => { expect(secondCall[1]).toEqual([ expect.stringContaining("/tmp/test-dir"), "/restore", - expect.stringContaining(".acl.tmp"), + expect.stringContaining("safeWriteText.acl"), ]) // dump file was unlinked after restore - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) it("win32 DACL save runs before the backup rename, not after it", async () => { @@ -610,7 +610,7 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledTimes(1) // dump file was still unlinked in finally - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) it("win32 DACL: when target does not exist, no save/restore/dump", async () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 85c6a071ba..e548e5c3b5 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -370,7 +370,7 @@ export async function safeWriteText( if (platform === "win32") { try { await fs.access(targetPath) // target exists? - const dumpPath = targetPath + ".acl.tmp" + const dumpPath = _tempName(dirPath, "safeWriteText.acl") const saved = await _saveDaclWindows(targetPath, dumpPath, options?.execFileRunner) if (saved) { // Only a successfully saved dump may be restored onto the From ba20ae8d098bde0b791ed15ce38a7c526771db7d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 07:07:36 +0800 Subject: [PATCH 25/42] test: third mocked-e2e attempt on this unit - the suite is green on the other eight units of the same chain with the same guard behaviour From eb49dd4f2dc95f97dec6357236e417724e05c18c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 10:58:55 +0800 Subject: [PATCH 26/42] chore(ci): re-run mocked E2E (apply_diff timeouts on the previous run; this unit was green on the earlier run) From 44a8d11b1641606a8cb8b7f2fd8adbf4726e9f21 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 11:32:17 +0800 Subject: [PATCH 27/42] chore(ci): second re-run of the mocked E2E suite. The apply_diff fixtures 404 on this run while the identical content passes on #1918, so the failure is in the mock fixture matching, not this unit's code. From 425b1268553c95b79b166f8fa21caec0e650c800 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 03:17:22 +0800 Subject: [PATCH 28/42] fix(tools): apply_diff must publish with edit-guard semantics, not create semantics This unit routes DiffViewProvider saves through guardedWrite and defaults saveDirectly's writeKind to "create" (correct for WriteToFileTool, which publishes a whole new file). apply_diff always edits an EXISTING file, so with the default the guard rejects the publish and the mocked E2E suite times out at 60s in all four "Roo Code apply_diff Tool" cases - the only layer that exercises this path, because this unit has no apply_diff unit spec. Pass "edit" at both publish sites (saveDirectly in the no-diff-view branch, saveChanges in the diff-view branch): edit semantics require a prior read observation, which apply_diff has (it read the file to compute the diff), instead of demanding that the target be absent. Adds src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts (the lowest layer that would have failed: it asserts the kind the tool passes) - the same spec the sibling unit carries. Local: tsc clean for the package, eslint clean on both files, no suppression change. The new spec cannot run on this machine (the shared node_modules junction has no vscode shim, so @roo-code/telemetry fails to import - it fails identically in the sibling branch where the spec is native), so CI is the arbiter. --- src/core/tools/ApplyDiffTool.ts | 6 +- .../applyDiffTool.guardedWrite.spec.ts | 195 ++++++++++++++++++ 2 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 3b664b3bd2..fa873b8e5d 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -173,7 +173,8 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } - // Save directly without showing diff view or opening the file + // Save directly without showing diff view or opening the file. The diff is + // applied to an existing file, so edit-guard semantics require a prior read. task.diffViewProvider.editType = "modify" task.diffViewProvider.originalContent = originalContent await task.diffViewProvider.saveDirectly( @@ -182,6 +183,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { false, diagnosticsEnabled, writeDelayMs, + "edit", ) } else { // Original behavior with diff view @@ -221,7 +223,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } // Track file edit operation diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts new file mode 100644 index 0000000000..62ff44f75a --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -0,0 +1,195 @@ +// npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import type { Task } from "../../task/Task" +import { ApplyDiffTool } from "../ApplyDiffTool" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original file content\n"), + }, +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Error: ${msg}`), + rooIgnoreError: vi.fn((filePath: string) => `Access denied: ${filePath}`), + createPrettyPatch: vi.fn(() => "mock-diff"), + }, +})) + +vi.mock("../../diff/stats", () => ({ + sanitizeUnifiedDiff: vi.fn((diff: string) => diff), + computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), +})) + +describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyDiffTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "consecutiveMistakeCountForApplyDiff" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "api" + | "diffStrategy" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockSaveChanges: MockedFunction<(...args: unknown[]) => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockSaveChanges = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + saveChanges: mockSaveChanges, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + consecutiveMistakeCountForApplyDiff: new Map(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + api: { + getModel: () => ({ id: "claude-sonnet-4-5" }), + } as unknown as Task["api"], + diffStrategy: { + applyDiff: vi.fn().mockResolvedValue({ success: true, content: "modified file content\n" }), + } as unknown as Task["diffStrategy"], + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }), + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyDiffTool() + }) + + it("publishes through the guarded saveDirectly with edit kind", async () => { + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("applying diff", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) + + it("passes the edit kind to the diff-view save path", async () => { + // Without focus disruption the tool saves through the diff view, and the + // kind it passes must carry its intent: a targeted edit, not the default + // full-file replacement. + // The settings double is rebuilt for this test so the focus-disruption + // experiment is off and the tool takes the diff-view save path. + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }), + } as unknown as Task["providerRef"] + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) +}) From 3c099669ecc8d3163c348bcb1c5e007e02868d64 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 11:43:05 +0800 Subject: [PATCH 29/42] fix(file-safety): durable-copy publish and confined writes for the apply-patch unit Brings this unit's file-safety core in line with #1910/#1912/#1913: - the backup is a copy, never a move, so the canonical path is present for the whole commit window. The destination is created with openSync(backupPath, "wx", 0o600) before any content exists at it (fs.copyFile otherwise picks the destination mode itself), chmod 0o600 after the copy clears a copied read-only attribute on Windows that would make the fsync open fail with EACCES, the copy is fsynced, and it is deleted once the commit rename publishes - nothing is ever renamed back; - safeWriteText rejects a staging file that is the target itself (same inode/device); - safeWriteJson accepts confineTo: the resolved publish target must sit inside the declared scope, checked before the lock and before anything is staged, with both sides canonicalized (scope through realpath, walking to the nearest existing ancestor when it does not exist yet and rethrowing anything but ENOENT). Tests: the copy-model safeWriteText spec plus a real-filesystem integration spec (no fs mocks); the safeWriteJson spec moves from the three-rename/rollback model to the copy model and gains four confinement cases; the lock-key spec accounts for the extra lstat the aliasing guard performs. 269 passed / 4 skipped across file-safety + safeWriteJson + lockKey + integrations/editor + guardedWrite; tsc and eslint clean, no suppression drift. --- .../safeWriteText.integration.spec.ts | 49 +++++ .../__tests__/safeWriteText.spec.ts | 201 +++++++++++------- src/services/file-safety/safeWriteText.ts | 137 +++++++----- .../__tests__/safeWriteJson.lockKey.spec.ts | 9 +- src/utils/__tests__/safeWriteJson.test.ts | 158 +++++++++----- src/utils/safeWriteJson.ts | 91 ++++++++ 6 files changed, 459 insertions(+), 186 deletions(-) create mode 100644 src/services/file-safety/__tests__/safeWriteText.integration.spec.ts diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts new file mode 100644 index 0000000000..81d758349e --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -0,0 +1,49 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { safeWriteText } from "../safeWriteText" + +// No fs mocks in this file: the point is to assert what a real filesystem ends up +// holding after a publish attempt, which the mocked spec cannot show. The failure is +// provoked with real filesystem semantics rather than with a stubbed call. +describe("safeWriteText against a real filesystem", () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-text-int-")) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it("publishes the new bytes and leaves no staging or backup residue", async () => { + const targetPath = path.join(dir, "target.txt") + await fs.writeFile(targetPath, "old bytes") + + // No platform override: the real platform's own durability and ACL steps run. + // A failed icacls restore in a throwaway temp directory is reported, not thrown, + // so the publish still lands. + await safeWriteText(targetPath, "new bytes", { backup: true }) + + expect(await fs.readFile(targetPath, "utf8")).toBe("new bytes") + expect(await fs.readdir(dir)).toEqual(["target.txt"]) + }) + + it("leaves the target bytes untouched when the commit cannot replace it", async () => { + // A regular file cannot be renamed over a directory, so the backup copy and + // the commit both fail on a real filesystem with no mocking at all. + const targetPath = path.join(dir, "target-dir") + await fs.mkdir(targetPath) + const inside = path.join(targetPath, "payload.txt") + await fs.writeFile(inside, "original bytes") + + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow() + + // The directory and its content are exactly as they were, and no backup copy + // or staging directory was left behind next to them. + expect(await fs.readFile(inside, "utf8")).toBe("original bytes") + expect(await fs.readdir(dir)).toEqual(["target-dir"]) + }) +}) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8b76906dda..7529305278 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -7,7 +7,6 @@ import * as path from "path" import { PostCommitDurabilityError, resolveLockKey, - RollbackFailureError, safeWriteText, StagingPathError, type SafeWriteTextOptions, @@ -15,6 +14,8 @@ import { // Full mock for fs/promises — all methods are vi.fn() stubs vi.mock("fs/promises", () => ({ + copyFile: vi.fn(), + chmod: vi.fn(), mkdir: vi.fn(), access: vi.fn(), rename: vi.fn(), @@ -335,15 +336,13 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "data", { backup: true, platform: "linux" }) - // the commit rename (temp -> target) still happened - expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + // the commit rename (temp -> target) still happened; it is the only rename + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) // the failing cleanup was the post-commit backup unlink expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) - // no rollback rename: the committed target is not restored from the backup - expect(fs.rename).toHaveBeenCalledTimes(2) - // the staging temp was already committed by the rename; nothing // temp-shaped is unlinked afterwards expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) @@ -362,18 +361,22 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow(PostCommitDurabilityError) - // The commit rename already published the new content, so the backup must - // not be renamed back over it: only target->backup and temp->target run. - expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) - expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) - expect(fs.rename).toHaveBeenCalledTimes(2) + // The commit rename already published the new content, and the backup was only + // ever a copy: the target was never moved, so there is nothing to rename back. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // The durability failure is reported, not swallowed - and the backup copy is not + // left beside the target where no caller could find it. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) }) }) - // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── + // ── Test 4: backup:true keeps old safeWriteJson semantics, copy-based ── describe("backup:true", () => { - it("renames target -> backup before commit, deletes backup on success", async () => { + it("copies target -> backup before commit without moving the target, deletes the copy on success", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -383,68 +386,94 @@ describe("safeWriteText", () => { // target was accessed (exists check) expect(fs.access).toHaveBeenCalledWith(targetPath) - // first rename: target -> backup - expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + // The backup is a copy: the canonical target is never moved away, so readers + // never see a missing file and no later step can clobber a concurrent publish. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).not.toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) - // second rename: temp -> target (realpath mock returns targetPath) - expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + // the only rename is the atomic commit temp -> target + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) - // backup was deleted on success + // backup copy was deleted on success expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) }) - it("rollback: on failure after rename target->backup, restores backup to target", async () => { + it("a failed commit does not move the target, so nothing has to be rolled back", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - // first rename (target->backup) succeeds, second fails - let callCount = 0 - vi.mocked(fs.rename).mockImplementation(async () => { - callCount++ - if (callCount === 1) return // target -> backup - if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails - return // the rollback rename succeeds - }) + // The commit rename is the only rename in the flow and it fails. + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("ENOSPC") - // rollback rename is the 3rd call (after target->backup and temp->target failure) - expect(fs.rename).toHaveBeenNthCalledWith(3, expect.stringContaining("safeWriteText.bak_"), targetPath) + // The target never left its path, so there is no restore rename and the + // pre-write content is still what a reader sees at targetPath. + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) - // temp was cleaned up on failure + // Both the backup copy and the staging temp are cleaned up on failure. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) - it("a failed rollback reports the partial state, not only the publish error", async () => { - // The content is still on disk, but only at the backup path. A caller that gets - // just the publish error has data it cannot find at the expected path. + it("creates the backup privately before its content exists, then fsyncs it", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - let callCount = 0 - vi.mocked(fs.rename).mockImplementation(async () => { - callCount++ - if (callCount === 1) return // target -> backup - if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails - throw new Error("EACCES") // the rollback rename fails too + + await safeWriteText(targetPath, "new data", { backup: true }) + + // The destination must exist with a private mode before copyFile writes anything + // into it: copyFile chooses the destination mode itself, so a restrictive target + // could otherwise leave a group/world-readable copy that a later chmod cannot + // undo. "wx" also means a pre-existing path is never silently reused. + const seedOpen = vi.mocked(fsSync.openSync).mock.calls.find(function (call) { + return String(call[0]).includes("safeWriteText.bak_") && call[1] === "wx" }) + expect(seedOpen).toBeDefined() + expect(seedOpen?.[2]).toBe(0o600) + const seedOrder = vi.mocked(fsSync.openSync).mock.invocationCallOrder[vi.mocked(fsSync.openSync).mock.calls.indexOf(seedOpen!)] + expect(seedOrder).toBeLessThan(vi.mocked(fs.copyFile).mock.invocationCallOrder[0]) + + // The chmod keeps a copied read-only attribute (Windows) from breaking the fsync + // open, and keeps a backup of a permissive file private. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.chmod).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"), 0o600) + expect(vi.mocked(fs.chmod).mock.invocationCallOrder[0]).toBeGreaterThan( + vi.mocked(fs.copyFile).mock.invocationCallOrder[0], + ) - let failure: RollbackFailureError | undefined - await safeWriteText(targetPath, "new data", { backup: true }).catch((e: unknown) => { - if (e instanceof RollbackFailureError) { - failure = e - return + // The copy is then opened for fsync with the writable flag. + const backupOpen = vi.mocked(fsSync.openSync).mock.calls.find(function (call) { + return String(call[0]).includes("safeWriteText.bak_") && call[1] === "r+" + }) + expect(backupOpen).toBeDefined() + }) + + it("a failed backup flush is reported and leaves no partial backup behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The copy lands, but the fsync of the copy fails: the retained content is not + // known to be durable, so the write must not proceed on a half-written backup. + // The staged temp is fsynced earlier with a different handle, so target the + // backup's fd specifically. + vi.mocked(fsSync.openSync).mockImplementation((p: unknown) => (String(p).includes("safeWriteText.bak_") ? 7 : 1)) + vi.mocked(fsSync.fsyncSync).mockImplementation((fd: unknown) => { + if (fd === 7) { + throw new Error("EIO") } - throw e }) - expect(failure).toBeInstanceOf(RollbackFailureError) - expect(failure?.publishError).toBeInstanceOf(Error) - expect((failure?.publishError as Error).message).toBe("ENOSPC") - expect((failure?.rollbackError as Error).message).toBe("EACCES") - expect(failure?.backupPath).toContain("safeWriteText.bak_") - // The backup is what the caller can still recover, so it must stay on disk. - expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EIO") + + // Nothing was published, and the incomplete copy is removed rather than left + // next to the target looking like a usable backup. + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) it("backup:true when target does not exist: no backup created, just commit", async () => { @@ -566,32 +595,34 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) - it("win32 DACL save runs before the backup rename, not after it", async () => { + it("win32 DACL save runs before the backup copy, not after it", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) // The title is about order, so assert the order the mocks were actually - // called in. If the save ran after the backup rename the target would - // already be gone and the dump would describe the wrong file. + // called in. If the save ran after the backup copy the dump could describe a + // file that a concurrent publish had already replaced. await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) const callOrder = vi.mocked(execFile).mock.invocationCallOrder + const copyOrder = vi.mocked(fs.copyFile).mock.invocationCallOrder const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder const saveCall = callOrder[0] const restoreCall = callOrder[1] - const backupRename = renameOrder[0] - const commitRename = renameOrder[1] + const backupCopy = copyOrder[0] + const commitRename = renameOrder[0] - expect(saveCall).toBeLessThan(backupRename) - expect(backupRename).toBeLessThan(commitRename) + expect(saveCall).toBeLessThan(backupCopy) + expect(backupCopy).toBeLessThan(commitRename) expect(commitRename).toBeLessThan(restoreCall) }) - it("win32 DACL: dump is unlinked even when restore fails", async () => { + it("win32 DACL: a failed restore is reported and the dump is still unlinked", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) // icacls save succeeds, restore fails let callCount = 0 @@ -605,10 +636,15 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "data", { platform: "win32" }) - // write succeeded despite restore failure (best-effort) + // The content did commit: failing here would break every publish on a machine + // where icacls cannot reapply the saved ACEs. expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) expect(fs.rename).toHaveBeenCalledTimes(1) + // The changed access rights are reported instead of being swallowed. + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("could not be restored")) + warnSpy.mockRestore() + // dump file was still unlinked in finally expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) @@ -1067,35 +1103,48 @@ describe("caller-supplied staging path", () => { expect(fsSync.openSync).not.toHaveBeenCalled() expect(fs.rename).not.toHaveBeenCalled() }) + + it("rejects a staging path that is the target itself", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // Same inode and device for the supplied staging path and the target: the + // failure handler would unlink the only copy of the content, so a failed + // write would delete the file it was meant to protect. + const stats = _fileStats(false) + stats.ino = 42 + stats.dev = 7 + vi.mocked(fs.lstat).mockResolvedValue(stats) + + await expect( + safeWriteText(targetPath, "data", { tempPath: targetPath, platform: "linux" }), + ).rejects.toThrow(StagingPathError) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).not.toHaveBeenCalled() + }) }) -describe("cleanup before a rollback failure is reported", () => { +describe("cleanup when a backed-up write fails before commit", () => { beforeEach(() => mockDefaults()) - it("releases the staged file and its own staging directory before throwing", async () => { + it("releases the staged file, its copy and its own staging directory before throwing", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - let callCount = 0 - vi.mocked(fs.rename).mockImplementation(async () => { - callCount++ - if (callCount === 1) return // target -> backup - if (callCount === 2) throw new Error("ENOSPC") // temp -> target fails - throw new Error("EACCES") // the rollback rename fails too - }) + // The commit rename is the only rename in this flow and it fails. + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) - await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( - RollbackFailureError, - ) + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow("ENOSPC") - // The backup is what the caller can still recover, so it stays on disk; the - // staging file and this write's own directory must not leak alongside it. + // The staging file and this write's own directory must not leak, and neither may + // the backup copy: the target still holds the pre-write content on disk. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) const stagingDirs = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0])) expect(stagingDirs.length).toBe(1) expect(fs.rmdir).toHaveBeenCalledWith(stagingDirs[0]) - const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[2] + const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[0] const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] const rmdirOrder = vi.mocked(fs.rmdir).mock.invocationCallOrder[0] expect(unlinkOrder).toBeGreaterThan(failingRenameOrder) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e548e5c3b5..de2207e8b5 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -5,10 +5,12 @@ import { execFile } from "child_process" export interface SafeWriteTextOptions { /** - * When true, preserve the old-file semantics: rename target -> backup first, - * after commit rename delete the backup; on failure roll the backup back to - * the target path. When false (default) the atomic rename simply replaces - * the target -- crash-safe window is zero. + * When true, keep the old-file semantics without ever removing the target: the + * previous content is copied to a hidden backup path and flushed before the + * commit rename, the commit rename atomically replaces the target, and on success + * the backup copy is deleted. A failure before the commit leaves the target + * untouched (there is nothing to roll back) and removes the backup copy. When + * false (default) the atomic rename simply replaces the target. */ backup?: boolean @@ -34,29 +36,6 @@ export interface SafeWriteTextOptions { tempPath?: string } -/** - * A publish that failed and whose rollback also failed: the content survives only - * at the backup path, not at the canonical target. The publish failure stays the - * cause, and the rollback failure plus the backup location travel with the error so - * the caller can tell what it is looking at. - */ -export class RollbackFailureError extends Error { - readonly publishError: unknown - readonly rollbackError: unknown - readonly backupPath: string - - constructor(publishError: unknown, rollbackError: unknown, backupPath: string) { - super( - `Publish failed (${publishError instanceof Error ? publishError.message : String(publishError)}) and the backup could not be restored to its original path -- the content is preserved at the backup location reported on this error.`, - { cause: publishError }, - ) - this.name = "RollbackFailureError" - this.publishError = publishError - this.rollbackError = rollbackError - this.backupPath = backupPath - } -} - /** * A caller-supplied staging path that is not a file this write may publish: it * sits outside the target's directory (so the commit rename would cross @@ -140,8 +119,8 @@ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunne } /** Restore a DACL dump onto *dirPath* on Windows. - * Best-effort: content is already committed, so failure is non-fatal. */ -async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + * Returns whether icacls succeeded; the caller reports a failure. */ +async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { @@ -149,8 +128,9 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu err ? reject(err) : resolve(), ) }) + return true } catch { - // best-effort; content already committed + return false } } @@ -211,7 +191,8 @@ async function canonicalDirKey(absoluteFilePath: string): Promise { * Lock key for a publish target: the symlink referent when the path is an * existing symlink, the path itself otherwise. Unlike resolvePublishTarget this * tolerates a dangling link, because the lock key has to be computable while a - * peer writer is mid-commit (backup mode renames the referent away and back). + * peer writer is mid-commit (a publish renames the staged file onto the referent, + * and backup mode keeps a copy beside it). * The walk is bounded so a two-link cycle terminates, and every key it returns is * canonicalized through canonicalDirKey. */ @@ -276,6 +257,20 @@ export async function safeWriteText( supplied, ) } + // A staging path that is the target would be unlinked by the failure handler + // while it still holds the only copy of the content, so a failed write would + // delete the file it was meant to protect. Compare identities, not spellings: + // an alias of the target is the same hazard. + const targetStat = await fs.lstat(targetPath).catch(() => null) + if ( + targetStat && + typeof stagingStat.ino === "number" && + typeof targetStat.ino === "number" && + stagingStat.ino === targetStat.ino && + stagingStat.dev === targetStat.dev + ) { + throw new StagingPathError("Staging file must not be the target itself", supplied) + } // The caller's own path is used as given; only the check is canonical. tempPath = options.tempPath } else { @@ -292,12 +287,6 @@ export async function safeWriteText( // Non-null only when the win32 step-2 block saved a successful DACL dump: // it gates the step-5 restore and is tracked for the cleanup unlinks. let daclDumpPath: string | null = null - // Set when the rollback itself fails, so cleanup runs before the error that - // reports the partial state is thrown. - // Held as a pair so the reported error still names the path the content survived at; - // declaring it as `unknown` alone would lose the string narrowing at the throw site. - let rollbackFailure: { error: unknown; backupPath: string } | null = null - try { // -- Step 1: write content to staging temp file ------------------- if (!options?.tempPath) { @@ -365,7 +354,7 @@ export async function safeWriteText( } } - // -- Step 2 (win32): save DACL BEFORE backup rename --------------- + // -- Step 2 (win32): save DACL BEFORE the backup copy ----------- const platform = options?.platform ?? process.platform if (platform === "win32") { try { @@ -389,12 +378,45 @@ export async function safeWriteText( } try { - // -- Step 3 (backup:true): rename target -> backup -------------- + // -- Step 3 (backup:true): durable copy target -> backup ---- if (options?.backup) { try { await fs.access(targetPath) backupPath = _tempName(dirPath, "safeWriteText.bak") - await fs.rename(targetPath, backupPath) + // Copy, never move. Renaming the target away leaves the canonical path absent for + // the whole commit window: readers see a missing file, and a concurrent + // writer can create a new target that a later rollback would destroy. A copy + // keeps the target present, so the step 4 rename is the only change to the + // canonical path. The copy is flushed so the retained content survives a crash. + try { + // Create the destination BEFORE any content exists at it, with the mode fixed + // at open time. fs.copyFile picks the destination mode itself (the platform + // creation mask subject to umask on some platforms, the source's mode - or its + // read-only attribute - on others), so letting it create the file would either + // leave a restrictive target's bytes briefly readable to others, or leave the + // copy unwritable so the fsync open below fails with EACCES. open() ignores its + // mode argument for an existing file, so this 0o600 survives the copy on POSIX; + // the chmod afterwards is what clears a copied read-only attribute on Windows + // and keeps a backup of a permissive file private. + const seedFd = fsSync.openSync(backupPath, "wx", 0o600) + fsSync.closeSync(seedFd) + await fs.copyFile(targetPath, backupPath) + await fs.chmod(backupPath, 0o600) + // "r+" not "r": fsync on a read-only handle is EPERM on Windows, and the same + // flag the staged temp file uses above. + const backupFd = fsSync.openSync(backupPath, "r+") + try { + _fsyncFile(backupFd) + } finally { + fsSync.closeSync(backupFd) + } + } catch (backupError: unknown) { + // A partial backup must not outlive this attempt: it is not a complete copy + // of anything, and once the write fails nothing else removes it. + await fs.unlink(backupPath).catch(() => {}) + backupPath = null + throw backupError + } releaseBackupOnSuccess = true } catch (err: unknown) { if (errorCode(err) !== "ENOENT") throw err @@ -432,7 +454,16 @@ export async function safeWriteText( // and on every failed save. if (daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) - await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + const restored = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + if (!restored) { + // The content is committed, but the published file may carry a different DACL + // from the one that was saved. Failing the write here would break every + // publish on machines where icacls cannot reapply the saved ACEs (a plain + // temp directory restore fails with "Not all privileges or groups referenced + // are assigned to the caller"), so the change of access rights is reported + // rather than thrown. + console.warn(`safeWriteText: content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file may carry different access rights than the one it replaced.`) + } } // -- Step 6 (backup:true): delete backup on success ----------- @@ -463,19 +494,13 @@ export async function safeWriteText( // Only a pre-commit failure can restore the backup. Once the commit rename // published, a later failure (for example the post-commit directory fsync) // must not overwrite the published content with the old file. - if (backupPath && releaseBackupOnSuccess && !committed) { - try { - await fs.rename(backupPath, targetPath) - } catch (rollbackError: unknown) { - // The content survives only at the backup path now, and the canonical - // target is gone. Reporting just the publish failure would leave the - // caller with data it cannot find at the expected path, so the - // partial-failure state travels with the error. The staged temp file - // and this write's staging directory are released first: a rollback - // failure is already a hard enough state to reason about without also - // leaking the staging file. - rollbackFailure = { error: rollbackError, backupPath } - } + if (backupPath && releaseBackupOnSuccess) { + // Nothing to restore: the backup is a copy, so the target still holds whatever + // the commit left there - before the commit that is the pre-write content, and + // after it the published content. Either way the copy has served its purpose + // and must not be left beside the target where no caller can find it. + await fs.unlink(backupPath).catch(() => {}) + backupPath = null } try { await fs.unlink(tempPath).catch(() => {}) @@ -494,10 +519,6 @@ export async function safeWriteText( await fs.unlink(daclDumpPath).catch(() => {}) } - if (rollbackFailure) { - throw new RollbackFailureError(originalError, rollbackFailure.error, rollbackFailure.backupPath) - } - throw originalError } } diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 33989f8b81..beddaf9ea9 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -96,10 +96,11 @@ describe("safeWriteJson lock key under a peer commit", () => { // The lock key is the key every other writer to this file uses, so the caller // queued behind the peer instead of failing before the lock. expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) - // The trailing lstat is safeWriteText's staging-path check on the temp file - // this write created: it runs after the key was resolved and the lock taken, - // so it does not change which lock the caller queued behind. - expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "resolve", "resolve", "lstat"]) + // The two trailing lstat calls are safeWriteText's staging-path checks: the + // regular-file check on the temp file this write created, and the identity check + // that the staging path is not the target. Both run after the key was resolved + // and the lock was taken, so neither changes which lock the caller queued behind. + expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "resolve", "resolve", "lstat", "lstat"]) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) }) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 245af61910..10db2f21d2 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -3,8 +3,7 @@ import { Writable } from "stream" import * as path from "path" import * as os from "os" -import { safeWriteJson } from "../safeWriteJson" -import { RollbackFailureError } from "../../services/file-safety/safeWriteText" +import { ConfinedPathEscapeError, safeWriteJson } from "../safeWriteJson" import * as lockfile from "proper-lockfile" // Capture actual implementations before the vi.mock factory runs, @@ -160,7 +159,7 @@ describe("safeWriteJson", () => { expect(content).toEqual({ initial: "content" }) }) - test("should handle failure when renaming filePath to tempBackupFilePath (filePath exists)", async () => { + test("should handle failure when the commit rename fails (filePath exists)", async () => { const initialData = { message: "Initial content, should remain" } const newData = { message: "New content, should not be written" } @@ -179,7 +178,7 @@ describe("safeWriteJson", () => { expect(content).toEqual(initialData) }) - test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup succeeded)", async () => { + test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup copy taken)", async () => { const initialData = { message: "Initial content, should be restored" } const newData = { message: "New content" } @@ -193,14 +192,8 @@ describe("safeWriteJson", () => { vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { renameCallCount++ if (renameCallCount === 1) { - // First call: filePath -> tempBackupFilePath (should succeed) - return fsPromisesActuals.rename!(oldPath, newPath) - } else if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (should fail) + // The commit rename is the only rename in this flow: it fails. throw new Error("Rename from temp to final failed") - } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, should succeed) - return fsPromisesActuals.rename!(oldPath, newPath) } // Default: use original implementation return fsPromisesActuals.rename!(oldPath, newPath) @@ -351,16 +344,11 @@ describe("safeWriteJson", () => { await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) - // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - let renameCallCount = 0 - vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { - renameCallCount++ - if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (should fail) - throw new Error("Rename failed") - } - // For all other calls, use the original implementation - return fsPromisesActuals.rename!(oldPath, newPath) + // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn. + // Once-only so the override does not leak into later tests: the commit rename is + // the only rename in this flow. + vi.mocked(fs.rename).mockImplementationOnce(async () => { + throw new Error("Rename failed") }) await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Rename failed") @@ -440,9 +428,10 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // Test for rollback failure scenario (the rollback rename now lives in safeWriteText) - test("re-throws the original error when the rollback rename fails, leaving an orphaned backup", async () => { - const initialData = { message: "Initial, orphaned when rollback fails" } + // The backup is a copy taken before the commit, so a failed commit has nothing to + // roll back: the target keeps its previous content and the copy is removed. + test("a failed commit keeps the previous content at the target and removes the backup copy", async () => { + const initialData = { message: "Initial, must survive a failed commit" } const newData = { message: "New content" } await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) @@ -453,38 +442,24 @@ describe("safeWriteJson", () => { let renameCallCount = 0 vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { renameCallCount++ - if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (fail) + if (renameCallCount === 1) { + // The commit rename fails; there is no rollback rename to fail. throw new Error("Primary rename failed") - } else if (renameCallCount === 3) { - // Third call: backup -> filePath (rollback, also fail) - throw new Error("Rollback rename failed") } return fsPromisesActuals.rename!(oldPath, newPath) }) - // The original error must propagate, not the rollback error - // The rollback also failed, so the error reports the partial state: the publish - // failure stays the cause and the backup location is named. - let failure: RollbackFailureError | undefined - await safeWriteJson(currentTestFilePath, newData).catch((e: unknown) => { - if (e instanceof RollbackFailureError) { - failure = e - return - } - throw e - }) + await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") - expect(failure).toBeInstanceOf(RollbackFailureError) - expect(failure?.cause).toBeInstanceOf(Error) - expect(failure?.rollbackError).toBeInstanceOf(Error) - expect(failure?.backupPath).toContain("safeWriteText.bak_") + // Exactly one rename was attempted, and it was the commit. + expect(renameCallCount).toBe(1) - // The rollback failed inside safeWriteText, so the target is gone and - // the backup is orphaned on disk. - expect(await fileExists(currentTestFilePath)).toBe(false) + // The target never left its path, so the previous content is still what a + // reader sees, and no orphaned backup copy is left behind either. + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(initialData) const entries = await fs.readdir(tempDir) - expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(false) consoleErrorSpy.mockRestore() }) @@ -690,6 +665,93 @@ describe("safeWriteJson", () => { // via tempPath, so safeWriteText must apply the existing target's mode to // the staged temp before the atomic rename — otherwise a 0o600 target is // published as 0o644. POSIX-only assertion (Windows ignores POSIX modes). + test("rejects a confined write whose target is outside the confined directory", async () => { + const scope = path.join(tempDir, "project") + await fs.mkdir(scope) + const outside = path.join(tempDir, "elsewhere.json") + + // No symlink needed: the check runs on the resolved publish target, so an + // out-of-scope path is rejected on every platform, and it is rejected before the + // lock is taken and before anything is staged. + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: scope })).rejects.toThrow( + ConfinedPathEscapeError, + ) + + const left = await fs.readdir(tempDir) + expect(left).not.toContain("elsewhere.json") + expect(left.filter((entry) => entry.includes(".new_") || entry.endsWith(".lock"))).toEqual([]) + }) + + test.skipIf(process.platform === "win32")( + "rejects a confined write whose symlink resolves outside the confined directory", + async () => { + const projectDir = path.join(tempDir, "project") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "outside.json") + await fsSyncActual.promises.writeFile(outside, JSON.stringify({ secret: "original" }), "utf8") + // A repository that plants its project settings file as a link to somewhere else + // must not receive the settings write at the linked path. The caller picked + // projectDir/mcp.json from the workspace, so it declares that scope. + const projectConfig = path.join(projectDir, "mcp.json") + await fs.symlink(outside, projectConfig) + + await expect( + safeWriteJson(projectConfig, { mcpServers: {} }, { confineTo: projectDir }), + ).rejects.toThrow(ConfinedPathEscapeError) + + // The linked file is untouched and nothing was staged beside it. + expect(JSON.parse(await fsSyncActual.promises.readFile(outside, "utf8"))).toEqual({ secret: "original" }) + const entries = await fs.readdir(tempDir) + expect(entries).toContain("outside.json") + expect( + entries.filter((entry) => entry.includes(".new_") || entry.includes("safeWriteText") || entry.endsWith(".lock")), + ).toEqual([]) + }, + ) + + test.skipIf(process.platform === "win32")( + "confines a write whose symlink referent stays inside the confined directory", + async () => { + const projectDir = path.join(tempDir, "project-in") + await fs.mkdir(projectDir) + const referent = path.join(projectDir, "real-mcp.json") + await fsSyncActual.promises.writeFile(referent, JSON.stringify({ mcpServers: {} }), "utf8") + const alias = path.join(projectDir, "mcp.json") + await fs.symlink(referent, alias) + + // Confining is about the scope, not about forbidding links: a link that stays + // inside the project still publishes to its referent. + await safeWriteJson(alias, { mcpServers: { local: { url: "http://localhost" } } }, { confineTo: projectDir }) + + expect(JSON.parse(await fsSyncActual.promises.readFile(referent, "utf8"))).toEqual({ + mcpServers: { local: { url: "http://localhost" } }, + }) + }, + ) + + test.skipIf(process.platform === "win32")( + "confines a scope path that itself runs through a symlink and does not exist yet", + async () => { + const real = path.join(tempDir, "real-project") + await fs.mkdir(real) + const alias = path.join(tempDir, "alias-project") + await fs.symlink(real, alias) + // The scope is declared through the alias, and the directory it names does not + // exist yet. Resolving it lexically would compare an unresolved scope against a + // fully resolved target and reject a write that is in fact inside the project - + // the macOS /var -> /private/var shape. The nearest existing ancestor is resolved + // and the remainder re-joined instead. + const nested = path.join(alias, "nested") + const target = path.join(nested, "mcp.json") + + await safeWriteJson(target, { mcpServers: {} }, { confineTo: nested }) + + expect( + JSON.parse(await fsSyncActual.promises.readFile(path.join(real, "nested", "mcp.json"), "utf8")), + ).toEqual({ mcpServers: {} }) + }, + ) + test.skipIf(process.platform === "win32")( "preserves a restrictive 0o600 target mode through the atomic publish", async () => { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index bae200dd38..ab6f7d145a 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -32,6 +32,78 @@ export interface SafeWriteJsonOptions { * cannot be parsed. */ merge?: (existing: unknown, incoming: unknown) => unknown + + /** + * Restrict the write to a directory. The publish target is resolved through + * symlinks before this check runs, so a caller that picked the path from a + * known scope (a workspace, a project settings directory) can refuse a write + * that a planted symlink would land somewhere else. The check runs before the + * advisory lock is taken and before anything is staged. + */ + confineTo?: string +} + +/** + * Thrown when a write declared with `confineTo` resolves outside that directory. + */ +export class ConfinedPathEscapeError extends Error { + constructor( + readonly requestedPath: string, + readonly resolvedPath: string, + readonly confineTo: string, + ) { + super( + `Refusing to write ${resolvedPath}: it resolves outside the confined directory ${confineTo} (requested ${requestedPath}).`, + ) + this.name = "ConfinedPathEscapeError" + } +} + +/** + * Canonicalize the directory a write is confined to. The publish target is fully + * resolved through symlinks, so the scope has to be resolved the same way or a + * scope path that itself runs through a symlink (macOS /var -> /private/var is the + * common case) would compare lexically against a resolved target and reject every + * legitimate in-scope write. When the scope does not exist yet, the nearest + * existing ancestor is resolved and the remainder re-appended. + */ +async function _resolveScopeRoot(confineTo: string): Promise { + const lexical = path.resolve(confineTo) + try { + return await fs.realpath(lexical) + } catch (error: unknown) { + // Only a missing path means "walk up and re-join". EACCES or ELOOP means the + // scope cannot be canonicalized at all, and continuing would build a partly + // lexical root that can disagree with the canonical target - the failure has to + // surface rather than decide the scope from a guess. + if (_scopeErrorCode(error) !== "ENOENT") { + throw error + } + const missing: string[] = [] + let ancestor = lexical + while (true) { + const parent = path.dirname(ancestor) + if (parent === ancestor) { + return lexical + } + missing.push(path.basename(ancestor)) + ancestor = parent + try { + const real = await fs.realpath(ancestor) + return path.join(real, ...missing.reverse()) + } catch (innerError: unknown) { + if (_scopeErrorCode(innerError) !== "ENOENT") { + throw innerError + } + } + } + } +} + +function _scopeErrorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined } /** @@ -89,6 +161,25 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // advisory lock held until the stale timeout for every other writer. resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // Confinement, if the caller declared a scope. Both sides are canonicalized the + // same way: the publish target is resolved through symlinks, and a target that + // does not exist yet still carries the alias components of the path it was + // given. This runs before the merge read and before anything is staged, so a + // rejected write leaves nothing behind. + if (options?.confineTo) { + const scopeRoot = await _resolveScopeRoot(options.confineTo) + const resolvedTarget = await _resolveScopeRoot(resolvedTargetPath) + const relative = path.relative(scopeRoot, resolvedTarget) + if ( + relative === "" || + relative === ".." || + relative.startsWith(".." + path.sep) || + path.isAbsolute(relative) + ) { + throw new ConfinedPathEscapeError(absoluteFilePath, resolvedTargetPath, scopeRoot) + } + } + // If a merge callback was provided, read the current file under the lock // and let the caller merge before we write. Must be inside try/finally // so a throwing merge still releases the lock. From 1741810c1bc1f81eb53c425500beb56742e0c7ca Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 12:16:07 +0800 Subject: [PATCH 30/42] fix(file-safety): do not read a failed target lstat as a missing target The staging-identity guard compared the caller-staged file with the target through fs.lstat(targetPath).catch(() => null). Any error other than ENOENT - EACCES, ENOTDIR, ELOOP - was therefore indistinguishable from "there is no target", so a staging path that is a hard link of the target would pass the identity check, reach the commit rename, and let the failure handler unlink the only copy of the content. Only ENOENT now yields "no target"; anything else fails closed with a StagingPathError before anything is opened, staged or renamed. Test: the staging path resolves to a hard link of the target (same ino/dev) and the target lstat rejects with EACCES; the write is rejected with the comparison error and neither openSync nor rename is called. Verified as a real regression test - reverting the catch to () => null makes it fail. 270 passed / 4 skipped across file-safety + safeWriteJson + lockKey + integrations/editor + guardedWrite; project-wide tsc clean; eslint clean. --- .../__tests__/safeWriteText.spec.ts | 25 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 11 +++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 7529305278..8abdbafe8a 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1122,7 +1122,30 @@ describe("caller-supplied staging path", () => { expect(fs.rename).not.toHaveBeenCalled() expect(fs.unlink).not.toHaveBeenCalled() }) -}) + + + it("rejects when the target identity cannot be compared for a reason other than a missing target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // A hard-linked staging file shares the target's inode, so the identity comparison is the only thing + // between this write and a rename onto the very file the guard protects. An EACCES from the target + // lstat must not be mistaken for "there is no target". + const stagingStats = _fileStats(false) + stagingStats.ino = 42 + stagingStats.dev = 7 + vi.mocked(fs.lstat).mockImplementation(async (p) => { + if (String(p) === targetPath) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + return stagingStats + }) + + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/hardlink.txt", platform: "linux" }), + ).rejects.toThrow("Staging file could not be compared with the target") + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + })}) describe("cleanup when a backed-up write fails before commit", () => { beforeEach(() => mockDefaults()) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index de2207e8b5..cc866e6fcd 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -261,7 +261,16 @@ export async function safeWriteText( // while it still holds the only copy of the content, so a failed write would // delete the file it was meant to protect. Compare identities, not spellings: // an alias of the target is the same hazard. - const targetStat = await fs.lstat(targetPath).catch(() => null) + // Only a missing target may be skipped: an EACCES/ELOOP/ENOTDIR here means the + // identity comparison could not be made, and treating that as "no target" would let a + // staging alias reach the commit and let cleanup delete the file it was meant to + // protect. + const targetStat = await fs.lstat(targetPath).catch((error: unknown) => { + if (errorCode(error) !== "ENOENT") { + throw new StagingPathError("Staging file could not be compared with the target", supplied) + } + return null + }) if ( targetStat && typeof stagingStat.ino === "number" && From 38cf4c6d7ee467753930813526870bcfe2e29c0a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 12:40:56 +0800 Subject: [PATCH 31/42] fix(file-safety): compare staging and target identity with bigint stats The staging-identity guard read fs.Stats.ino/dev as JS numbers. On NTFS and ReFS those identifiers can exceed Number.MAX_SAFE_INTEGER, and the rounding makes two different files look identical - a valid caller-staged file is then rejected with StagingPathError (and a real alias could be missed). Both lstats now request { bigint: true } and the comparison checks for bigint values before comparing them. The spec stand-in carries bigint ino/dev, matching what lstat({ bigint: true }) returns at runtime (fs.BigIntStats is a type-only export, so the double is documented at its single assertion). --- .../__tests__/safeWriteText.spec.ts | 19 +++++++++++++------ src/services/file-safety/safeWriteText.ts | 11 +++++++---- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8abdbafe8a..a912bb0657 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -67,6 +67,17 @@ function _fileStats(isLink: boolean): fsSync.Stats { return s } +// fs.BigIntStats is a type-only export (fs.BigIntStats is undefined at runtime), so the stand-in is +// a Stats object carrying bigint ino/dev - exactly what fs.lstat(path, { bigint: true }) hands +// back at runtime. +function _fileStatsWithIdentity(ino: bigint, dev: bigint): fsSync.BigIntStats { + // Double assertion: the runtime value is the Stats stand-in, the type is the bigint variant. + const s = _fileStats(false) as unknown as fsSync.BigIntStats + s.ino = ino + s.dev = dev + return s +} + function mockDefaults(): void { vi.resetAllMocks() // After resetAllMocks, vi.fn() returns undefined — restore promise defaults. @@ -1110,9 +1121,7 @@ describe("caller-supplied staging path", () => { // Same inode and device for the supplied staging path and the target: the // failure handler would unlink the only copy of the content, so a failed // write would delete the file it was meant to protect. - const stats = _fileStats(false) - stats.ino = 42 - stats.dev = 7 + const stats = _fileStatsWithIdentity(42n, 7n) vi.mocked(fs.lstat).mockResolvedValue(stats) await expect( @@ -1130,9 +1139,7 @@ describe("caller-supplied staging path", () => { // A hard-linked staging file shares the target's inode, so the identity comparison is the only thing // between this write and a rename onto the very file the guard protects. An EACCES from the target // lstat must not be mistaken for "there is no target". - const stagingStats = _fileStats(false) - stagingStats.ino = 42 - stagingStats.dev = 7 + const stagingStats = _fileStatsWithIdentity(42n, 7n) vi.mocked(fs.lstat).mockImplementation(async (p) => { if (String(p) === targetPath) { throw Object.assign(new Error("EACCES"), { code: "EACCES" }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index cc866e6fcd..a7bd862739 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -250,7 +250,10 @@ export async function safeWriteText( supplied, ) } - const stagingStat = await fs.lstat(supplied) + // BigInt stats: on NTFS/ReFS the file identity can exceed Number.MAX_SAFE_INTEGER, and + // a rounded number makes two different files look identical (rejecting a valid staging + // file) or hides a real alias. + const stagingStat = await fs.lstat(supplied, { bigint: true }) if (stagingStat.isSymbolicLink() || !stagingStat.isFile()) { throw new StagingPathError( `Staging file must be a regular file, not ${stagingStat.isSymbolicLink() ? "a symlink" : "another file type"}`, @@ -265,7 +268,7 @@ export async function safeWriteText( // identity comparison could not be made, and treating that as "no target" would let a // staging alias reach the commit and let cleanup delete the file it was meant to // protect. - const targetStat = await fs.lstat(targetPath).catch((error: unknown) => { + const targetStat = await fs.lstat(targetPath, { bigint: true }).catch((error: unknown) => { if (errorCode(error) !== "ENOENT") { throw new StagingPathError("Staging file could not be compared with the target", supplied) } @@ -273,8 +276,8 @@ export async function safeWriteText( }) if ( targetStat && - typeof stagingStat.ino === "number" && - typeof targetStat.ino === "number" && + typeof stagingStat.ino === "bigint" && + typeof targetStat.ino === "bigint" && stagingStat.ino === targetStat.ino && stagingStat.dev === targetStat.dev ) { From c949784278870f5bf293f5cd52214897d35e93e5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 12:58:41 +0800 Subject: [PATCH 32/42] test(file-safety): pin the bigint options in the staging-identity tests The identity stand-in returns bigint identifiers regardless of the options, so the same-file tests could still pass if either lstat dropped { bigint: true } - which is exactly the case that matters on NTFS/ReFS. Both tests now assert that every identity lstat requested bigint stats. The calls are filtered by their options rather than by path spelling: path.resolve prefixes a drive letter to /tmp/... on Windows, so a path filter would only see one of the two reads on that platform. --- .../file-safety/__tests__/safeWriteText.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a912bb0657..b0d08fee7f 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1129,6 +1129,15 @@ describe("caller-supplied staging path", () => { ).rejects.toThrow(StagingPathError) expect(fsSync.openSync).not.toHaveBeenCalled() expect(fs.rename).not.toHaveBeenCalled() + // The comparison is only sound when both stats are read as bigint: on NTFS/ReFS the file + // identifiers exceed Number.MAX_SAFE_INTEGER. + // Filter on the options, not the spelling: path.resolve prefixes a drive letter on Windows, + // so the two identity reads are the calls that asked for options at all. + const identityLookups = vi.mocked(fs.lstat).mock.calls.filter((c) => c[1] !== undefined) + expect(identityLookups.length).toBeGreaterThanOrEqual(2) + for (const c of identityLookups) { + expect(c[1]).toEqual({ bigint: true }) + } expect(fs.unlink).not.toHaveBeenCalled() }) @@ -1152,6 +1161,13 @@ describe("caller-supplied staging path", () => { ).rejects.toThrow("Staging file could not be compared with the target") expect(fsSync.openSync).not.toHaveBeenCalled() expect(fs.rename).not.toHaveBeenCalled() + // Both identity reads must ask for bigint stats, or the comparison silently falls + // back to rounded numbers on NTFS/ReFS. + const identityLookups = vi.mocked(fs.lstat).mock.calls.filter((c) => c[1] !== undefined) + expect(identityLookups).toHaveLength(2) + for (const c of identityLookups) { + expect(c[1]).toEqual({ bigint: true }) + } })}) describe("cleanup when a backed-up write fails before commit", () => { From 365d43049d9d858fddb05d571f9eaa2592198540 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 13:13:21 +0800 Subject: [PATCH 33/42] fix(file-safety): report a Windows replacement whose DACL was not preserved On win32 the DACL of an existing target is saved before the commit rename so it can be reapplied afterwards. Two paths silently skipped that step and still published: - icacls /save failed (saved === false): the dump is cleaned up and the rename proceeds, so the new file inherits different access rights; - fs.access(targetPath) failed with something other than ENOENT (EACCES, ...): the catch treated "cannot check" as "target absent" and skipped DACL handling entirely. Both now report through a new onWarning sink (default console.warn): the write still proceeds - a missing or failing icacls must not leave the user unable to save, which is the documented fallback - but the caller is told the replacement may inherit different access rights instead of discovering it later. Tests: icacls save failure still commits the write and yields exactly one access-rights warning; an EACCES on the target yields the could-not-check warning and no icacls call. Verified as real regression tests - neutralizing the two warn calls makes both fail. 272 passed / 4 skipped locally; tsc and eslint clean. --- .../__tests__/safeWriteText.spec.ts | 37 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 29 +++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b0d08fee7f..a20df2b9f9 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -554,6 +554,43 @@ describe("safeWriteText", () => { expect(execFile).toHaveBeenCalledTimes(1) }) + it("win32: reports that access rights may change when the DACL cannot be saved", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + const warnings: string[] = [] + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + + // The write still commits - a failing icacls must not leave the user unable to save - + // but the caller is told the replacement may not carry the old ACL. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(warnings.filter((m) => m.includes("different access rights"))).toHaveLength(1) + }) + + it("win32: reports when the target cannot be checked for DACL preservation", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The target exists but is not readable: that is not "absent", and skipping DACL + // preservation has to be visible. + vi.mocked(fs.access).mockImplementation(async (p) => { + if (String(p) === targetPath) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + }) + const warnings: string[] = [] + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + + expect(execFile).not.toHaveBeenCalled() + expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) + }) + it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index a7bd862739..fbdd2feaef 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -27,6 +27,14 @@ export interface SafeWriteTextOptions { */ execFileRunner?: typeof execFile + /** + * Sink for non-fatal safety notices. A Windows DACL that could not be captured means the + * committed file may inherit different access rights: the write still proceeds (a missing or + * failing icacls must not block saving), but the caller is told instead of the change being + * silent. Defaults to console.warn. + */ + onWarning?: (message: string) => void + /** * Pre-written temp path to use for the commit phase. When provided, * safeWriteText skips creating its own staging file and uses this path @@ -368,9 +376,15 @@ export async function safeWriteText( // -- Step 2 (win32): save DACL BEFORE the backup copy ----------- const platform = options?.platform ?? process.platform + const warn = options?.onWarning ?? ((message: string) => console.warn(message)) if (platform === "win32") { + let accessError: unknown = null try { await fs.access(targetPath) // target exists? + } catch (error: unknown) { + accessError = error + } + if (accessError === null) { const dumpPath = _tempName(dirPath, "safeWriteText.acl") const saved = await _saveDaclWindows(targetPath, dumpPath, options?.execFileRunner) if (saved) { @@ -382,13 +396,20 @@ export async function safeWriteText( // remove it now (best-effort) so no partial dump survives and // no later step can restore from it. await fs.unlink(dumpPath).catch(() => {}) + // The target exists and its DACL could not be captured, so the commit rename + // replaces it with a file that inherits different access rights. The write still + // proceeds - a missing or failing icacls must not leave the user unable to save - + // but the replacement is no longer ACL-identical and that has to be visible + // instead of silent. + warn(`Could not save the DACL of ${targetPath}; the replacement may inherit different access rights.`) } - } catch { - // target does not exist or access failed — no DACL handling - daclDumpPath = null + } else if (errorCode(accessError) !== "ENOENT") { + // Not "absent": the target is there but could not be checked (EACCES, ...), so + // DACL preservation was skipped for a reason the caller cannot infer from the + // successful write alone. + warn(`Could not check ${targetPath} for DACL preservation (${errorCode(accessError) ?? "unknown error"}); the replacement may inherit different access rights.`) } } - try { // -- Step 3 (backup:true): durable copy target -> backup ---- if (options?.backup) { From 3e6ce04f39a0a8f3a92bb1ad433ec4eb83676eef Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 14:44:53 +0800 Subject: [PATCH 34/42] fix(file-safety): keep DACL warning delivery non-fatal and complete Two gaps in the win32 DACL path: - the restore failure (icacls /restore commonly fails without the required privileges) called console.warn directly, so a caller that supplied onWarning never learned the committed file may carry a different ACL. It now goes through the same warn binding as the save-side notices. - warning delivery was not isolated from the write: an onWarning callback that threw propagated to the outer failure handler before fs.rename, turning a documented non-fatal notice into a failed save. Delivery now catches callback failures and logs them. Tests: restore-failure notice reaches onWarning (2 icacls calls, write still commits) and a throwing onWarning leaves the write succeeding. Negative control on both fixes: 2 failed; with the fix: 59/59 green. eslint clean, no suppression growth. --- .../__tests__/safeWriteText.spec.ts | 50 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 16 +++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a20df2b9f9..f46c2068e8 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -591,6 +591,56 @@ describe("safeWriteText", () => { expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) }) + it("win32: reports through onWarning when the saved DACL cannot be restored", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The dump succeeds and the restore fails: icacls /restore commonly fails without the + // required privileges, and the committed file may then carry a different ACL. + let icaclsCalls = 0 + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + icaclsCalls++ + if (typeof cb === "function") { + if (icaclsCalls === 1) { + cb(null, "", "") + } else { + cb(new Error("icacls restore error"), "", "") + } + } + return fakeChild + }) + const warnings: string[] = [] + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + + // The content is committed and the caller is told about the access-rights change. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(icaclsCalls).toBe(2) + expect(warnings.filter((m) => m.includes("could not be restored"))).toHaveLength(1) + }) + + // Warning delivery is advisory: it must not be able to fail the save it is reporting on. + it("win32: a throwing onWarning does not abort the write", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + + await expect( + safeWriteText(targetPath, "data", { + platform: "win32", + onWarning: () => { + throw new Error("callback down") + }, + }), + ).resolves.toBeUndefined() + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + }) + it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index fbdd2feaef..7978b3fa8e 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -376,7 +376,19 @@ export async function safeWriteText( // -- Step 2 (win32): save DACL BEFORE the backup copy ----------- const platform = options?.platform ?? process.platform - const warn = options?.onWarning ?? ((message: string) => console.warn(message)) + // Warning delivery must never abort the write: the notices below describe a + // committed-but-imperfect publish, and a caller whose callback throws (a UI sink, + // a logger that is mid-restart) must not turn that into a failed save. + const warn = (message: string) => { + try { + const sink = options?.onWarning ?? ((m: string) => console.warn(m)) + sink(message) + } catch (error: unknown) { + console.warn( + `safeWriteText: onWarning callback failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } if (platform === "win32") { let accessError: unknown = null try { @@ -495,7 +507,7 @@ export async function safeWriteText( // temp directory restore fails with "Not all privileges or groups referenced // are assigned to the caller"), so the change of access rights is reported // rather than thrown. - console.warn(`safeWriteText: content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file may carry different access rights than the one it replaced.`) + warn(`safeWriteText: content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file may carry different access rights than the one it replaced.`) } } From 789d47e2fbeae1088c366b9c40754ae9962b0215 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 15:44:37 +0800 Subject: [PATCH 35/42] fix(utils): check confinement before taking the advisory lock proper-lockfile creates ${lockKey}.lock beside the lock key, and the key is the symlink referent. A repository that plants .roo/mcp.json -> ~/.ssh/config therefore made a confined write create a lock directory OUTSIDE the declared scope, and when that directory was not writable the caller got a lock-acquisition error after up to five retries instead of ConfinedPathEscapeError. The confinement check now runs on the lock key before acquireFileLock and is repeated on the resolved publish target inside the lock (a peer writer may have moved the referent in between); both checks share _assertWithinScope so they canonicalize identically. Ported across the file-safety unit series so every unit that declares a scope carries the same guarantee (same fix as fws/u3-observation-completeness). --- src/utils/__tests__/safeWriteJson.test.ts | 31 +++++++++++++++++ src/utils/safeWriteJson.ts | 41 +++++++++++++++++------ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 10db2f21d2..21f354fa74 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -764,4 +764,35 @@ describe("safeWriteJson", () => { expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) }, ) + + // Ordering matters for the security guarantee: proper-lockfile creates + // ${lockKey}.lock beside the lock key, and the key is the symlink referent. If + // confinement were checked only after the lock, an out-of-scope target would first + // create a lock directory outside the scope. A lock mock that throws if reached + // proves the check runs first. Written without symlinks so it runs on every lane. + test("rejects an out-of-scope target before the advisory lock is taken", async () => { + vi.resetModules() + const projectDir = path.join(tempDir, "order-project-plain") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "order-outside-plain.json") + + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn(async () => { + throw new Error("lock taken for an out-of-scope target (test)") + }) + vi.doMock("proper-lockfile", () => ({ ...realLockfile, lock: lockMockFn })) + const { safeWriteJson: lockedSafeWriteJson } = await import("../safeWriteJson") + + try { + await expect(lockedSafeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + /resolves outside the confined directory/, + ) + expect(lockMockFn).not.toHaveBeenCalled() + const entries = await fs.readdir(tempDir) + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + } finally { + vi.doUnmock("proper-lockfile") + vi.resetModules() + } + }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index ab6f7d145a..2be5695241 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -100,6 +100,24 @@ async function _resolveScopeRoot(confineTo: string): Promise { } } +/** + * Reject a candidate publish path that escapes the caller's confined scope. + * Shared by the pre-lock check and the in-lock check so both canonicalize the + * same way: the candidate is resolved through symlinks and compared against the + * resolved scope root. + */ +function _assertWithinScope(requestedPath: string, candidatePath: string, scopeRoot: string): void { + const relative = path.relative(scopeRoot, candidatePath) + if ( + relative === "" || + relative === ".." || + relative.startsWith(".." + path.sep) || + path.isAbsolute(relative) + ) { + throw new ConfinedPathEscapeError(requestedPath, candidatePath, scopeRoot) + } +} + function _scopeErrorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code @@ -146,6 +164,18 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // back), so the walk tolerates a dangling link instead of rejecting it here. const lockKey = await resolveLockKey(absoluteFilePath) + // Confinement, if the caller declared a scope, is checked BEFORE the lock is + // taken: proper-lockfile creates ${lockKey}.lock beside the lock key, and the key + // is the symlink referent, so a repository-planted link out of the scope would + // otherwise create a lock directory outside the scope (and an unwritable referent + // directory would surface a lock-acquisition error after retries instead of + // ConfinedPathEscapeError). Repeated on the resolved publish target inside the + // lock, since a peer writer may move the referent in between. + if (options?.confineTo) { + const scopeRoot = await _resolveScopeRoot(options.confineTo) + _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(lockKey), scopeRoot) + } + // Acquire the lock before any file operations. If acquisition fails it throws // immediately, and releaseLock stays a no-op so the finally block does not try // to release an unacquired lock. @@ -168,16 +198,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // rejected write leaves nothing behind. if (options?.confineTo) { const scopeRoot = await _resolveScopeRoot(options.confineTo) - const resolvedTarget = await _resolveScopeRoot(resolvedTargetPath) - const relative = path.relative(scopeRoot, resolvedTarget) - if ( - relative === "" || - relative === ".." || - relative.startsWith(".." + path.sep) || - path.isAbsolute(relative) - ) { - throw new ConfinedPathEscapeError(absoluteFilePath, resolvedTargetPath, scopeRoot) - } + _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(resolvedTargetPath), scopeRoot) } // If a merge callback was provided, read the current file under the lock From 93d59631b2049296d930953e04ed0f444d13c8d5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:14:20 +0800 Subject: [PATCH 36/42] fix(integrations): the preview observation must not authorize an approved edit ApplyDiffTool builds its content from a read, then DiffViewProvider.open() records an observation for the path. When the file changed between the tool's read and that preview, the preview token is the ONLY entry in the registry, and because the save is issued with kind "edit" the partial-observation rule accepts it: the tool's stale full-file content is published over the intervening change. DiffViewProvider now snapshots the observation that existed BEFORE open() touched the registry and, for an edit-kind save, restores it so the compare-and-swap runs against the version the caller's content was actually built on. With no pre-open observation the preview's entry is withdrawn (ObservationRegistry#forget) and the unobserved-edit guard rejects the save with the re-read remediation instead of publishing. Tests: a preview with no pre-open observation is rejected and leaves no authorization behind; a preview whose model read is still current still publishes the targeted edit. Negative control: removing the restore fails exactly the first test. Implements the plan in easonLiangWorldedtech/Zoo-Code#41 comment 6030807561. --- src/core/task/observationRegistry.ts | 10 +++ src/integrations/editor/DiffViewProvider.ts | 33 ++++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 66 +++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts index 0ef9115f21..78a1f3d4cc 100644 --- a/src/core/task/observationRegistry.ts +++ b/src/core/task/observationRegistry.ts @@ -49,6 +49,16 @@ export class ObservationRegistry { return this.entries.has(absolutePath) } + /** + * Drop the observation for one path. A caller that must revoke an + * authorization it did not earn - a preview that observed a version the model + * never read - needs this instead of clear(), which would also discard the + * observations other reads of the same task still rely on. + */ + forget(absolutePath: string): boolean { + return this.entries.delete(absolutePath) + } + clear(): void { this.entries.clear() } diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index caa3ee1d26..bb8c4d0e60 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -95,6 +95,15 @@ export class DiffViewProvider { * never unlinked. */ private placeholderVersion: string | undefined = undefined + /** + * The observation this path had BEFORE open() recorded the preview's own + * version token: null when there was none, undefined when this provider never + * opened a preview. open() observes the current on-disk version, so a file that + * changed between the tool's read and this preview leaves the preview token as + * the only entry - and a targeted save would then CAS against a version the + * caller never read, publishing stale content over the intervening change. + */ + private preOpenObservation: { version: string; complete: boolean } | null | undefined = undefined constructor( private cwd: string, @@ -109,6 +118,13 @@ export class DiffViewProvider { const absolutePath = path.resolve(this.cwd, relPath) this.isEditing = true + // Snapshot the authorization as it stands before this preview touches the + // registry; saveChanges(..., "edit") restores it below. + const priorObservation = this.taskRef.deref()?.observationRegistry.get(absolutePath) + this.preOpenObservation = priorObservation + ? { version: priorObservation.version, complete: priorObservation.complete } + : null + // Capture the current scroll position before we close the tab so we can // restore it after saving/reverting. const existingEditor = vscode.window.visibleTextEditors.find( @@ -513,6 +529,22 @@ export class DiffViewProvider { encodedContent = await vscode.workspace.encode(editedContent, { encoding: updatedDocument.encoding, }) + // The preview must not authorize the save it made unverifiable. Restore + // the observation that existed before open() so the compare-and-swap runs + // against the version the caller's content was built on; when there was + // none, drop the preview's entry so the unobserved-edit guard rejects the + // write and the caller gets the re-read remediation instead. + if (writeKind === "edit" && this.preOpenObservation !== undefined) { + if (this.preOpenObservation) { + saveTask.observationRegistry.observe( + absolutePath, + this.preOpenObservation.version, + this.preOpenObservation.complete, + ) + } else { + saveTask.observationRegistry.forget(absolutePath) + } + } await guardedWrite(saveTask, this.relPath, encodedContent, writeKind) } catch (error) { // Autosave can publish the modified side of the diff before the user @@ -1485,6 +1517,7 @@ export class DiffViewProvider { this.userTouchedDiffEditor = false this.snapshotPreviewTabs = [] this.placeholderVersion = undefined + this.preOpenObservation = undefined } /** diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index f1972efabd..95352e674b 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1401,6 +1401,72 @@ describe("DiffViewProvider", () => { expect(result.newProblemsMessage).toBe("") }) + const openPreview = async () => { + // Enough of the editor double for open() to find the diff editor again. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 1, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(editor as unknown as vscode.TextEditor) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.window).visibleTextEditors = [editor as unknown as vscode.TextEditor] + // openDiffEditor resolves from the document-open event, so fire it. + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => { + callback({ uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" } } as unknown as vscode.TextDocument) + }, 0) + return { dispose: vi.fn() } + }) + // fs/promises is mocked; open() only reads these BigIntStats fields when it + // stat-matches the preview read. + vi.mocked(fs.stat).mockResolvedValue({ + dev: 1n, + ino: 2n, + size: 5n, + mtimeNs: 100n, + ctimeNs: 100n, + } as unknown as BigIntStats) + ;(diffViewProvider as unknown as { editType: string }).editType = "modify" + await diffViewProvider.open("test.txt") + ;(diffViewProvider as unknown as { newContent?: string }).newContent = "new content" + } + + it("does not let the preview's own observation authorize a targeted edit", async () => { + // The tool read never observed this path, so the only entry the save could + // point at is the one open() records for the preview. Authorizing from that + // entry publishes the tool's content over anything that changed between the + // read and the preview, so the save must fall back to the unobserved-edit + // guard and be rejected with the re-read remediation. + mockTask.observationRegistry.clear() + await openPreview() + // The preview did record an observation - that is the entry under test. + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(true) + + await expect(diffViewProvider.saveChanges(false, 0, "edit")).rejects.toThrow( + /File not read yet/, + ) + expect(safeWriteText).not.toHaveBeenCalled() + // The preview's authorization was withdrawn rather than left behind. + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(false) + }) + + it("still publishes a targeted edit authorized by a pre-preview observation", async () => { + // The same preview must not break the legitimate case: the model read the + // file (partially) and nothing changed before the preview, so the restored + // pre-open observation authorizes the targeted edit. + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1", false) + await openPreview() + + await diffViewProvider.saveChanges(false, 0, "edit") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.txt`, Buffer.from("new content")) + }) + it("publishes the accepted content in the document's own encoding", async () => { // A utf8bom document: getText() returns the text without the BOM, so the // publish must go through VS Code's codec for the document's own From f56f3ccc04c0f37244ddeced60eb1996ca3f6428 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:28:22 +0800 Subject: [PATCH 37/42] fix(file-safety): handle async warning sinks and confine before mkdir Two review findings on this branch: 1. safeWriteText's warn wrapper could not catch a rejection from an async onWarning sink - TypeScript accepts a value-returning callback where a void one is expected - so the rejected promise was left unhandled, which under Node's default mode can end the process after a write that already succeeded. The wrapper now attaches a catch handler without awaiting (awaiting would let warning delivery delay a committed write, or stall it on a hung sink) and reports the rejection through the fallback sink. Test: an async sink that rejects; without the handler vitest reports the unhandled rejection. 2. safeWriteJson created the target's parent directory BEFORE the preflight confinement check, so a confined write to an out-of-scope path with a missing parent still created a directory outside confineTo. resolveLockKey and the confinement check need no directory to exist, so the check now runs first and the mkdir follows it; the in-lock check on the resolved publish target stays. Test: an out-of-scope target whose parent is missing is rejected and no directory, lock or staging residue is left beside the scope. --- .../__tests__/safeWriteText.spec.ts | 32 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 20 +++++++++--- src/utils/__tests__/safeWriteJson.test.ts | 17 ++++++++++ src/utils/safeWriteJson.ts | 27 ++++++++-------- 4 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index f46c2068e8..d3c1885c02 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -281,6 +281,38 @@ describe("safeWriteText", () => { }) }) + it("win32: a rejecting async onWarning does not abort the write or leak an unhandled rejection", async () => { + // TypeScript accepts an async sink where a void callback is expected, so the + // wrapper has to attach a handler to the returned promise: an unhandled + // rejection can end the process under Node's default mode, after a write that + // already succeeded. + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await expect( + safeWriteText(targetPath, "data", { + platform: "win32", + onWarning: async () => { + throw new Error("async sink down") + }, + }), + ).resolves.toBeUndefined() + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + // The rejection is reported through the fallback sink rather than surfacing as an + // unhandled rejection. + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining("onWarning callback rejected"), + ) + consoleWarn.mockRestore() + }) + // ── Test 2: fsync ordering ─────────────────────────────────────────────── describe("fsync ordering", () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 7978b3fa8e..f0295b9c56 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -380,13 +380,25 @@ export async function safeWriteText( // committed-but-imperfect publish, and a caller whose callback throws (a UI sink, // a logger that is mid-restart) must not turn that into a failed save. const warn = (message: string) => { + const report = (label: string, error: unknown) => { + console.warn( + `safeWriteText: onWarning callback ${label}: ${error instanceof Error ? error.message : String(error)}`, + ) + } try { const sink = options?.onWarning ?? ((m: string) => console.warn(m)) - sink(message) + const result: unknown = sink(message) + // A sink may be async - TypeScript accepts a value-returning callback where + // a void one is expected. Awaiting it would let warning delivery delay a + // write that has already committed (and hang it if the sink never settles), + // while leaving the promise unhandled turns a rejection into an unhandled + // rejection, which under Node's default mode can end the process after a + // successful write. Attach a handler without awaiting. + if (result instanceof Promise) { + result.catch((error: unknown) => report("rejected", error)) + } } catch (error: unknown) { - console.warn( - `safeWriteText: onWarning callback failed: ${error instanceof Error ? error.message : String(error)}`, - ) + report("failed", error) } } if (platform === "win32") { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 21f354fa74..b19c7fd911 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -795,4 +795,21 @@ describe("safeWriteJson", () => { vi.resetModules() } }) + + test("does not create the parent directory of an out-of-scope confined target", async () => { + const projectDir = path.join(tempDir, "scope-dir-project") + await fs.mkdir(projectDir) + // The parent does not exist yet: the mkdir in safeWriteJson would create it - + // a filesystem change outside confineTo - before the confinement check rejected + // the write. + const outside = path.join(tempDir, "scope-missing-parent", "nested.json") + + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + /resolves outside the confined directory/, + ) + + const entries = await fs.readdir(tempDir) + expect(entries).not.toContain("scope-missing-parent") + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 2be5695241..2314001d9f 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -150,25 +150,18 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // the target when the resolution itself rejects. let resolvedTargetPath: string | undefined - try { - await fs.mkdir(dirPath, { recursive: true }) - await fs.access(dirPath) - } catch (dirError: any) { - console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) - throw dirError - } - // Lock key: the symlink referent when the path is an existing symlink, so a // symlink alias and its referent share one lock. The key must be computable // while a peer writer is mid-commit (backup mode renames the referent away and // back), so the walk tolerates a dangling link instead of rejecting it here. const lockKey = await resolveLockKey(absoluteFilePath) - // Confinement, if the caller declared a scope, is checked BEFORE the lock is - // taken: proper-lockfile creates ${lockKey}.lock beside the lock key, and the key - // is the symlink referent, so a repository-planted link out of the scope would - // otherwise create a lock directory outside the scope (and an unwritable referent - // directory would surface a lock-acquisition error after retries instead of + // Confinement, if the caller declared a scope, is checked before ANY filesystem + // side effect of this call: the directory creation below would otherwise create a + // parent directory outside confineTo for an out-of-scope target, and + // proper-lockfile would create ${lockKey}.lock beside the lock key (a + // repository-planted link out of the scope would also make an unwritable referent + // directory surface a lock-acquisition error after retries instead of // ConfinedPathEscapeError). Repeated on the resolved publish target inside the // lock, since a peer writer may move the referent in between. if (options?.confineTo) { @@ -176,6 +169,14 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(lockKey), scopeRoot) } + try { + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + } catch (dirError: any) { + console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) + throw dirError + } + // Acquire the lock before any file operations. If acquisition fails it throws // immediately, and releaseLock stays a no-op so the finally block does not try // to release an unacquired lock. From 10d36fe1019f83a371881d205a794b10178f4609 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:44:00 +0800 Subject: [PATCH 38/42] fix(tools): ApplyDiffTool must observe the version its diff was built on The e2e apply_diff suite failed at the previous head: the mocked model calls apply_diff without a prior read_file, so the only entry in the observation registry was the one DiffViewProvider.open() records for the preview. With the preview no longer allowed to authorize the save, the guarded save correctly fell back to the unobserved-edit guard and rejected - the flow had no authorization of its own. ApplyDiffTool reads the file itself to compute the diff, so it now observes that read (stat around the read, observe only when the file did not change underneath it, same contract as ApplyPatchTool's hunk read). The save is then authorized against the version the diff was actually computed against: unchanged file -> the save proceeds; file changed after the read -> the compare-and-swap rejects and the stale content is not published. Tests: the tool records the observation with completeness unearned, and records nothing when the file changed during the read. Negative control: removing the observation fails exactly the first. --- src/core/tools/ApplyDiffTool.ts | 20 ++++++++ .../applyDiffTool.guardedWrite.spec.ts | 47 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index fa873b8e5d..4f18a1d08d 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -5,6 +5,7 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { getReadablePath } from "../../utils/path" +import { versionTokenOfStat } from "../../utils/versionToken" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { fileExistsAtPath } from "../../utils/fs" @@ -68,7 +69,26 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } + // The diff below is built from this exact read, so the save that follows must be + // authorized against the version captured here - not against whatever version the + // preview happens to stat afterwards. Same contract as ApplyPatchTool's hunk read: + // stat around the read and observe only when the file did not change underneath it. + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) const originalContent: string = await fs.readFile(absolutePath, "utf-8") + const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + // A tool read is not a model read: when the model already observed the file, + // keep the completeness it earned and only on the version it was earned on; + // with no prior observation this stays a partial observation of the version + // the diff was computed against. + const prior = task.observationRegistry.get(absolutePath) + const complete = + prior === undefined ? false : prior.complete === true && prior.version === preReadToken + task.observationRegistry.observe(absolutePath, preReadToken, complete) + } + } // Apply the diff to the original content const diffResult = (await task.diffStrategy?.applyDiff( diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 62ff44f75a..dbc6fe2d21 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -1,14 +1,26 @@ // npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +import path from "path" + import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import type { Task } from "../../task/Task" import { ApplyDiffTool } from "../ApplyDiffTool" +import { ObservationRegistry } from "../../task/observationRegistry" vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue("original file content\n"), + // The tool stats around its read to authorize the save against the version it + // actually read; one shared stats object means the file did not change. + stat: vi.fn().mockResolvedValue({ + dev: 1n, + ino: 2n, + size: 22n, + mtimeNs: 100n, + ctimeNs: 100n, + }), }, })) @@ -117,6 +129,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }), }), } as unknown as Task["providerRef"], + observationRegistry: new ObservationRegistry(), fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined), } as unknown as Task["fileContextTracker"], @@ -192,4 +205,38 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockHandleError).not.toHaveBeenCalled() }) + + it("authorizes the save against the version its own read was built on", async () => { + // The model never read this file, so the only authorization available at save + // time is the one the tool earns from its own hunk read. Without it the guarded + // save falls back to the preview's version token, which can describe a version + // the diff was never computed against. + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) as unknown as void + + const observation = mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts")) + expect(observation?.version).toBe( + "1:2:22:100:100", + ) + // A tool read is not a model read, so completeness stays unearned. + expect(observation?.complete).toBe(false) + }) + + it("does not authorize a read that changed underneath it", async () => { + const stat = vi.mocked((await import("fs/promises")).default.stat) + stat + .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n }) + .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 30n, mtimeNs: 200n, ctimeNs: 100n }) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) as unknown as void + + expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + }) }) From 9522626bada01353ce7ed700fe400583e90ec324 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:52:42 +0800 Subject: [PATCH 39/42] test(tools): type the apply_diff observation doubles for check-types The CI compile job rejected the new applyDiffTool.guardedWrite.spec.ts code: the task stub's Pick<> did not list observationRegistry, and the BigIntStats doubles passed to stat's mockResolvedValueOnce are partial (only the fields versionTokenOfStat reads - a full BigIntStats cannot be built against the mocked fs, so the double assertion is the narrowest option). --- .../tools/__tests__/applyDiffTool.guardedWrite.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index dbc6fe2d21..2a9791e4e6 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -1,6 +1,7 @@ // npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts import path from "path" +import type { BigIntStats } from "fs" import type { MockedFunction } from "vitest" @@ -58,6 +59,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { | "didEditFile" | "api" | "diffStrategy" + | "observationRegistry" | "diffViewProvider" | "providerRef" | "fileContextTracker" @@ -226,10 +228,12 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }) it("does not authorize a read that changed underneath it", async () => { + // Only the fields versionTokenOfStat reads; a full BigIntStats cannot be built + // against the mocked fs, so the double assertion is the narrowest option. const stat = vi.mocked((await import("fs/promises")).default.stat) stat - .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n }) - .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 30n, mtimeNs: 200n, ctimeNs: 100n }) + .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats) + .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 30n, mtimeNs: 200n, ctimeNs: 100n } as unknown as BigIntStats) await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { askApproval: mockAskApproval, From a955ec3c895bea904022e47ef94cd98edd56b700 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:08:37 +0800 Subject: [PATCH 40/42] fix(integrations): never adopt an autosaved match for an unauthorized edit The autosave adoption branch accepted ANY GuardRejectedError when the buffer was clean. With the preview no longer authorizing targeted saves, an "edit" save with no pre-open observation is rejected by the unobserved-edit guard - an authorization verdict, not a moved-token verdict. If VS Code autosave had already written the buffer, the adoption path reported success AND recorded a partial observation for a file the model never read, which would then authorize a later targeted publish. Adoption is now limited to saves that were authorized before open(). Test: the autosave shape with preOpenObservation = null rejects with the re-read remediation, performs no adoption read and grants no observation. Negative control: dropping the new condition fails exactly that test. Also removes the dead committed flag in safeWriteText (declared and set, never read) and corrects the comment that described a backup-restore guard which does not exist: the backup is a copy and is never restored. --- src/integrations/editor/DiffViewProvider.ts | 5 +++ .../editor/__tests__/DiffViewProvider.spec.ts | 33 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 10 ++---- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb8c4d0e60..b29c7ecedf 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -561,6 +561,11 @@ export class DiffViewProvider { error instanceof GuardRejectedError && saveTask && encodedContent && + // An edit with no pre-open observation was rejected for AUTHORIZATION, not for a + // moved token. Adopting the match would report success and record a partial + // observation for a file the model never read, which would then authorize a + // later edit publish - exactly what the unobserved-edit guard exists to prevent. + !(writeKind === "edit" && this.preOpenObservation === null) && // Only the autosave shape: a clean buffer means its content is what autosave // already put on disk. A dirty buffer means the disk content came from // someone else, so the discard cleanup below is still the right outcome. diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 95352e674b..480dd2f9d7 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2313,6 +2313,39 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) + it("does not adopt an autosaved match for an edit that was never authorized", async () => { + // Same autosave shape, different verdict: with no observation from before open() + // the guard rejects for AUTHORIZATION. Adopting the byte match would report a + // modified result and record a partial observation for a file the model never + // read, which would then authorize a later targeted publish. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + diffViewProvider["preOpenObservation"] = null + mockTask.observationRegistry.clear() + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false, 0, "edit")).rejects.toThrow(/File not read yet/) + + // No adoption read, and no observation granted. + expect(fs.readFile).not.toHaveBeenCalled() + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)).toBeUndefined() + }) + it("still rejects when the disk content does not match what the save intended", async () => { // Same autosave shape, different bytes: the guard verdict stands. const cleanEditor = { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index f0295b9c56..f957ef0825 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -300,10 +300,6 @@ export async function safeWriteText( let backupPath: string | null = null let releaseBackupOnSuccess = false - // Set once the commit rename has published the new content. After that point the - // backup is no longer a safe restore source: rolling it back would overwrite - // content the caller can already observe at the target path. - let committed = false // Non-null only when the win32 step-2 block saved a successful DACL dump: // it gates the step-5 restore and is tracked for the cleanup unlinks. let daclDumpPath: string | null = null @@ -482,7 +478,6 @@ export async function safeWriteText( // -- Step 4: atomic rename temp -> target --------------------- await fs.rename(tempPath, targetPath) - committed = true // -- Step 4b (POSIX): fsync the parent directory so the directory entry // changed by the commit rename is durable, not just the file content. @@ -548,9 +543,8 @@ export async function safeWriteText( await fs.rmdir(stagingDir).catch(() => {}) } } catch (originalError: unknown) { - // Only a pre-commit failure can restore the backup. Once the commit rename - // published, a later failure (for example the post-commit directory fsync) - // must not overwrite the published content with the old file. + // The backup is never restored: it is a copy, and the target already holds + // either the pre-write content (before the commit) or the published content. if (backupPath && releaseBackupOnSuccess) { // Nothing to restore: the backup is a copy, so the target still holds whatever // the commit left there - before the commit that is the pre-write content, and From 1659cae2387d0722ef990ba2b183f27fe00966e8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:33:16 +0800 Subject: [PATCH 41/42] fix(tools): never refresh a model observation the tool read on a different version ApplyDiffTool observed its own hunk read unconditionally, which replaced an older model observation with the current version. Content the model built from the older read would then pass the save's compare-and-swap. The observation is now only rewritten when there was no prior entry (the tool read is the only authorization apply_diff can have, since it computes its hunks from that read) or when the prior entry is on the same version, in which case the completeness the model earned is preserved. A prior entry on a different version is left alone, so the save fails and the model is told to re-read. Tests: prior complete + same version stays complete; prior complete + older version is left untouched (control: the previous unconditional rewrite fails exactly that test). Also restores the stat mock in afterEach - clearAllMocks drops call records but not queued once-values - and asserts the tool statted exactly twice. --- src/core/tools/ApplyDiffTool.ts | 20 +++++--- .../applyDiffTool.guardedWrite.spec.ts | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 4f18a1d08d..eeca26c198 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -79,14 +79,20 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { if (preReadStats && postReadStats) { const preReadToken = versionTokenOfStat(preReadStats) if (preReadToken === versionTokenOfStat(postReadStats)) { - // A tool read is not a model read: when the model already observed the file, - // keep the completeness it earned and only on the version it was earned on; - // with no prior observation this stays a partial observation of the version - // the diff was computed against. + // A tool read is not a model read. With no prior observation this stays a + // partial observation of the version the diff was computed against - the only + // authorization the save can have, since apply_diff computes its hunks from + // this read. When the model already observed the file, keep the completeness it + // earned, but only on the version it was earned on: refreshing an OLDER + // observation to the current version would let content the model built from a + // stale read pass the compare-and-swap, so an out-of-date observation is left + // alone and the save fails with the re-read remediation. const prior = task.observationRegistry.get(absolutePath) - const complete = - prior === undefined ? false : prior.complete === true && prior.version === preReadToken - task.observationRegistry.observe(absolutePath, preReadToken, complete) + if (prior === undefined) { + task.observationRegistry.observe(absolutePath, preReadToken, false) + } else if (prior.version === preReadToken) { + task.observationRegistry.observe(absolutePath, preReadToken, prior.complete === true) + } } } diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 2a9791e4e6..49a76cde84 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -70,6 +70,16 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + afterEach(async () => { + // clearAllMocks drops call records but not queued once-values, so stats queued by + // one test would otherwise be handed to the next test's reads. Restore the default. + const stat = vi.mocked((await import("fs/promises")).default.stat) + stat.mockReset() + stat.mockResolvedValue( + { dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats, + ) + }) + beforeEach(() => { vi.clearAllMocks() @@ -242,5 +252,42 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }) as unknown as void expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + // Exactly the two bracketing stats: no queued value left over for the next test. + expect(stat).toHaveBeenCalledTimes(2) + }) + + it("keeps a complete model observation complete when the tool read matches its version", async () => { + // The model read the file in full first. The tool's own read of the same version + // must not downgrade that earned completeness to a partial observation. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.observationRegistry.observe(key, "1:2:22:100:100", true) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) as unknown as void + + const observation = mockTask.observationRegistry.get(key) + expect(observation?.version).toBe("1:2:22:100:100") + expect(observation?.complete).toBe(true) + }) + + it("does not refresh an observation the model earned on an older version", async () => { + // Refreshing a stale observation to the current version would authorize content the + // model built from the older read. The observation is left as the model earned it, + // so the save's compare-and-swap fails and the model is told to re-read. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.observationRegistry.observe(key, "1:2:9:9:9", true) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) as unknown as void + + const observation = mockTask.observationRegistry.get(key) + expect(observation?.version).toBe("1:2:9:9:9") + expect(observation?.complete).toBe(true) }) }) From fcbe7fd8ca56ee674b75232fdc52b0f9f0fd630b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 20:44:25 +0800 Subject: [PATCH 42/42] test(tools),docs(utils): drop the void casts, describe the backup as a copy - applyDiffTool.guardedWrite.spec: four `await tool.execute(...) as unknown as void` suffixes were double assertions that changed nothing; execute already returns Promise, and the other tests in the same file await it plainly. - safeWriteJson: three comments still described the old backup contract (rename the target away, roll it back on failure). safeWriteText takes the backup as a COPY, never moves the target, and removes the copy on failure, so the comments now say that: the lock-key walk tolerates a dangling link because a create or a peer mid-staging can present one, Step 2 delegates backup + commit (not rollback), and a failed safeWriteText leaves the target holding the pre-write bytes. Local: 33 passed / 4 skipped across the two specs; eslint clean on both files. --- .../applyDiffTool.guardedWrite.spec.ts | 8 +++--- src/utils/safeWriteJson.ts | 25 +++++++++++-------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 49a76cde84..bf7bc4b520 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -227,7 +227,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - }) as unknown as void + }) const observation = mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts")) expect(observation?.version).toBe( @@ -249,7 +249,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - }) as unknown as void + }) expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() // Exactly the two bracketing stats: no queued value left over for the next test. @@ -266,7 +266,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - }) as unknown as void + }) const observation = mockTask.observationRegistry.get(key) expect(observation?.version).toBe("1:2:22:100:100") @@ -284,7 +284,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, - }) as unknown as void + }) const observation = mockTask.observationRegistry.get(key) expect(observation?.version).toBe("1:2:9:9:9") diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 2314001d9f..d5e559641b 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -151,9 +151,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso let resolvedTargetPath: string | undefined // Lock key: the symlink referent when the path is an existing symlink, so a - // symlink alias and its referent share one lock. The key must be computable - // while a peer writer is mid-commit (backup mode renames the referent away and - // back), so the walk tolerates a dangling link instead of rejecting it here. + // symlink alias and its referent share one lock. The key must be computable even + // when the link does not resolve yet - a create writes through a dangling link, + // and a peer writer can be caught between creating its staging file and the + // commit rename - so the walk tolerates a dangling link instead of rejecting it + // here. const lockKey = await resolveLockKey(absoluteFilePath) // Confinement, if the caller declared a scope, is checked before ANY filesystem @@ -230,11 +232,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) - // Step 2: Delegate backup + commit + rollback to safeWriteText with the - // pre-written temp path. backup:true keeps the old safeWriteJson - // semantics (target -> backup before commit, rollback on failure) and - // keeps the target in place until safeWriteText captures its Windows - // DACL (safeWriteText dumps the DACL before its own backup rename and + // Step 2: Delegate backup + commit to safeWriteText with the pre-written + // temp path. backup:true keeps the old safeWriteJson semantics: a COPY of the + // target is taken before the commit rename and the target itself is never + // moved, which also keeps it in place until safeWriteText captures its Windows + // DACL (safeWriteText dumps the DACL before taking the backup copy and // restores it onto the directory after the commit rename). const textOptions: SafeWriteTextOptions = { tempPath: actualTempNewFilePath, @@ -254,9 +256,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const newFileToCleanupWithinCatch = actualTempNewFilePath - // A failed safeWriteText already rolled the backup (if any) back to - // the target path. Clean up the .new file if it still exists - // (safeWriteText also cleans up its tempPath on failure; this is a + // A failed safeWriteText left the target alone: the commit rename is its last + // step, so the target still holds the pre-write bytes, and the backup copy it + // took is removed by safeWriteText itself. Clean up the .new file if it still + // exists (safeWriteText also cleans up its tempPath on failure; this is a // safety net in case its cleanup missed it). if (newFileToCleanupWithinCatch) { try {