From d5f8a79c75e74be66078ec8a9884a056ccc628bf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 20:34:31 +0800 Subject: [PATCH 01/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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 272171bf38bc48e6bb7d1dd33a5ee48dcf6d7d50 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 00:02:51 +0800 Subject: [PATCH 13/73] chore(ci): rerun the e2e-mock lane Same four apply_diff tests 404 on the mock server here while the identical code passes e2e at U7 and U9. From 057643563c1e1fbd78053bc5913167c2bd61f565 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 02:49:21 +0800 Subject: [PATCH 14/73] 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 09ff089e339d62290f009c1046fcd4664413b253 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:04:51 +0800 Subject: [PATCH 15/73] test: rerun mocked e2e - no source change, previous run failed in the runner From 7d100e320356557ee8690ca145a6a65705e4bdcf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:44:38 +0800 Subject: [PATCH 16/73] 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 dee0ae558074fc98687e7ab271cbc5f64c3c7e28 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:45:59 +0800 Subject: [PATCH 17/73] 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 f2ba27460884f96d9eb937415f59cf68a4d11281 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 05:21:46 +0800 Subject: [PATCH 18/73] test: re-trigger required checks - the queued runs were cancelled by the Actions queue, no source change From 28470560bc83ec656ae41abae7c6d15c78ab8368 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 05:51:16 +0800 Subject: [PATCH 19/73] test: re-run mocked e2e and the ubuntu lane - no source change since the last green run on these files From d0dd137b88bfd595458dc04839ad8618d1c45ae2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 06:19:18 +0800 Subject: [PATCH 20/73] test: re-run mocked e2e - the suite passed on the previous head of this branch and no source changed From a9943a9acbaf4aa5093d41a60f8988d1f9981cbd Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 06:47:38 +0800 Subject: [PATCH 21/73] 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 8dc0d81378e25807659234d7761c28aa263bf5ea Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 07:07:39 +0800 Subject: [PATCH 22/73] 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 47316275ae66f0476dbf3c630e0cc0d3b3a9803f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 10:59:01 +0800 Subject: [PATCH 23/73] chore(ci): re-run mocked E2E (apply_diff timeouts on the previous run; this unit was green on the earlier run) From 6a289d70a287082f1e37f6657bb9902575a1f162 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 11:32:21 +0800 Subject: [PATCH 24/73] 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 f94017fcbb02b8c706c0f48ead76cc5ccac43444 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 03:18:16 +0800 Subject: [PATCH 25/73] fix(tools): apply_diff must publish with edit-guard semantics, not create semantics Same defect as fws/u6 (425b12685), same fix. This unit routes DiffViewProvider saves through guardedWrite and defaults saveDirectly's writeKind to "create". apply_diff always edits an existing file, so the guard rejects the publish and all four "Roo Code apply_diff Tool" cases in the mocked E2E suite time out after 60s - the only layer that exercises the path, since this unit has no apply_diff unit spec of its own. 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; create semantics demand that the target be absent, which it never is. Adds the sibling unit's applyDiffTool.guardedWrite.spec.ts as the lowest-layer regression test. Local: package tsc clean, eslint clean on both files, no suppression change. The spec cannot run on this machine (no vscode shim in the shared node_modules junction; it fails the same way in the 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 be4ee707bb51e9fb9d8cc362a900472268c1213c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 11:58:08 +0800 Subject: [PATCH 26/73] fix(file-safety): durable-copy publish and confined writes for the diff-view unit Brings this unit's file-safety core in line with #1910/#1912/#1913/#1915: - the backup is a copy, never a move, so the canonical path stays 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. Tests: the copy-model safeWriteText spec plus a real-filesystem integration spec; 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 clean (project-wide, no new errors), 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 95c3b9569662911fa0db653f9ff18f6e7edbd6a9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 12:19:46 +0800 Subject: [PATCH 27/73] 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. --- .../__tests__/safeWriteText.spec.ts | 25 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 10 +++++++- 2 files changed, 33 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..b2568524a0 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -261,7 +261,15 @@ 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; treating that as "no target" would let a staging alias + // reach the commit rename and let cleanup delete the file the guard protects. + 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 eefb2d1685f02e529618dad6c57b75d8f13c99d5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 12:41:12 +0800 Subject: [PATCH 28/73] 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 b2568524a0..663af594da 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"}`, @@ -264,7 +267,7 @@ export async function safeWriteText( // Only a missing target may be skipped. An EACCES/ELOOP/ENOTDIR here means the identity // comparison could not be made; treating that as "no target" would let a staging alias // reach the commit rename and let cleanup delete the file the guard protects. - 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) } @@ -272,8 +275,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 73ebb0d51c763b886f7bfd1a6139ccd7dd51d76c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 12:58:52 +0800 Subject: [PATCH 29/73] 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 73e6bbae176213c99146a9b9416e8d2144f7b4a3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 13:13:30 +0800 Subject: [PATCH 30/73] 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 | 36 ++++++++++++++---- 2 files changed, 66 insertions(+), 7 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 663af594da..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 @@ -264,9 +272,10 @@ 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. - // Only a missing target may be skipped. An EACCES/ELOOP/ENOTDIR here means the identity - // comparison could not be made; treating that as "no target" would let a staging alias - // reach the commit rename and let cleanup delete the file the guard protects. + // 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, { bigint: true }).catch((error: unknown) => { if (errorCode(error) !== "ENOENT") { throw new StagingPathError("Staging file could not be compared with the target", supplied) @@ -367,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) { @@ -381,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 acded64be0657891a26acba798dc76e3e8266a0f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 14:00:18 +0800 Subject: [PATCH 31/73] fix(diff-view): undo a partial open() instead of leaving an unapproved file open() creates the parent directories and, for a new file, writes an empty placeholder before it opens the diff editor. When openDiffEditor() rejects there is no active diff editor, so revertChanges() has nothing to undo and the empty file - plus the directories created for it - stays on disk even though the edit was never approved. undoPartialOpen() now runs on that failure: under the same resolved-path advisory lock every other writer uses, it unlinks the placeholder only while the on-disk file still carries the version token this open() wrote, then removes the directories this call created innermost-first (rmdir stops at the first directory another writer populated). The tracked open state is cleared and the original error is re-thrown. Test: DiffViewProvider spec - with showTextDocument rejecting, assert the placeholder is unlinked and both created directories are removed innermost-first, and that createdDirs/placeholderVersion are cleared. Verified as a real regression test (removing the undo call fails it). 131/131 in the spec; eslint clean with no suppression growth. --- src/integrations/editor/DiffViewProvider.ts | 57 ++++++++++++++++++- .../editor/__tests__/DiffViewProvider.spec.ts | 26 +++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index caa3ee1d26..8cd7ea2a74 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -247,7 +247,19 @@ export class DiffViewProvider { // diff evicts them (VS Code reuses the group's single preview slot). this.snapshotPreviewTabs = this.captureUnrelatedPreviewTabs(absolutePath) - this.activeDiffEditor = await this.openDiffEditor() + try { + this.activeDiffEditor = await this.openDiffEditor() + } catch (error) { + // open() has already created the parent directories and, for a new file, written + // an empty placeholder. With no active diff editor there is nothing for + // revertChanges() to undo, so the unapproved file (and the directories made for + // it) would stay on disk: undo that part of the open here. + await this.undoPartialOpen(absolutePath) + this.isEditing = false + this.createdDirs = [] + this.placeholderVersion = undefined + throw error + } this.fadedOverlayController = new DecorationController("fadedOverlay", this.activeDiffEditor) this.activeLineController = new DecorationController("activeLine", this.activeDiffEditor) // Apply faded overlay to all lines initially. @@ -323,6 +335,49 @@ export class DiffViewProvider { }) } + /** + * Undo the on-disk side effects of an open() that failed before the diff editor was + * created. open() creates the parent directories and writes an empty placeholder + * for a new file; if the diff editor never opens, revertChanges() has no diff + * editor to revert and the unapproved file would stay on disk. The placeholder is + * removed only while it is still the exact file this open() wrote (same version + * token), under the same resolved-path advisory lock every other writer to this + * path uses; the directories this call created then go, innermost first, stopping + * at the first directory another writer populated in the meantime. + */ + private async undoPartialOpen(absolutePath: string): Promise { + if (this.editType !== "create") { + return + } + const placeholderVersion = this.placeholderVersion + try { + await withFileLock(await resolveLockKey(absolutePath), async () => { + if (placeholderVersion) { + const stats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!stats || versionTokenOfStat(stats) !== placeholderVersion) { + // Another writer owns the path now: leave their file alone. + return + } + try { + await fs.unlink(absolutePath) + } catch { + // The placeholder vanished or the unlink failed; keep the directories. + return + } + } + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch { + break + } + } + }) + } catch { + // Best-effort: the open() failure is the outcome the caller sees. + } + } + async update(accumulatedContent: string, isFinal: boolean) { if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) { throw new Error("Required values not set") diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index f1972efabd..f341d03117 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -11,6 +11,7 @@ import { computeVersionToken, versionTokenOfStat } from "../../../utils/versionT import type { BigIntStats } from "fs" import { safeWriteText } from "../../../services/file-safety/safeWriteText" import { withFileLock } from "../../../utils/fileLock" +import { createDirectoriesForFile } from "../../../utils/fs" import { ObservationRegistry } from "../../../core/task/observationRegistry" import type { Task } from "../../../core/task/Task" @@ -1549,6 +1550,31 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["placeholderVersion"]).toBe(placeholderToken) }) + it("open() removes the placeholder and its new directories when the diff editor never opens", async () => { + // open() creates the parent directories and writes the empty placeholder before + // it opens the diff. If the diff never opens there is no active diff editor for + // revertChanges() to undo, so an unapproved empty file (and the directories made + // for it) would stay on disk. + const createdDirs = [`${mockCwd}/t3-partial`, `${mockCwd}/t3-partial/nested`] + vi.mocked(createDirectoriesForFile).mockResolvedValueOnce(createdDirs) + vi.mocked(vscode.window.showTextDocument).mockRejectedValue(new Error("Cannot open file")) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockReturnValue({ dispose: vi.fn() }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + + await expect(diffViewProvider.open("t3-partial/nested/new.ts")).rejects.toThrow(/Failed to/) + + // The placeholder this open() wrote is removed, and the directories it created + // go with it, innermost first. + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(`${mockCwd}/t3-partial/nested/new.ts`) + expect(vi.mocked(fs.rmdir)).toHaveBeenNthCalledWith(1, `${mockCwd}/t3-partial/nested`) + expect(vi.mocked(fs.rmdir)).toHaveBeenNthCalledWith(2, `${mockCwd}/t3-partial`) + expect(diffViewProvider["createdDirs"]).toEqual([]) + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + 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 From 954de798df02b337e34f29c77ff842ff6f832958 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 14:25:46 +0800 Subject: [PATCH 32/73] fix(file-safety): route the DACL restore failure through onWarning too SafeWriteTextOptions.onWarning is the documented sink for non-fatal safety notices, and Step 2 already uses it for both DACL warnings. Step 5 still called console.warn directly, so a caller that supplies onWarning never learned that the committed file may carry a different ACL - the more likely failure, since icacls /restore commonly fails without the required privileges. The warn binding is already in scope. --- 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 fbdd2feaef..4cfc6cdd90 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -495,7 +495,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 4ee44fb9899df001d9a240fbea484bbca5682aa0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 14:47:22 +0800 Subject: [PATCH 33/73] fix(file-safety): keep DACL warning delivery from failing the save onWarning is documented as the sink for non-fatal safety notices, but delivery was not isolated from the write: an onWarning callback that threw propagated to the outer failure handler before fs.rename, turning a non-fatal notice into a failed save. The warn binding now catches callback failures and logs them. The restore-failure notice is routed through the same binding so a caller supplying onWarning receives it. Same fix as the u6 unit, kept aligned across the series. Local: safeWriteText 58/58 green; eslint clean, no suppression growth. --- .../__tests__/safeWriteText.spec.ts | 22 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 14 +++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a20df2b9f9..b658cca80a 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -591,6 +591,28 @@ describe("safeWriteText", () => { expect(warnings.filter((m) => m.includes("Could not check"))).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 4cfc6cdd90..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 { From eb33473c7f1d300b2e2d28197b4defccd9c4a9ce Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 15:44:40 +0800 Subject: [PATCH 34/73] 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 9cb15d0bf99cbfd9c30be5cbffbf46ea4f516438 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:15:31 +0800 Subject: [PATCH 35/73] 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 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 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. Ported across the unit series so every unit that wires saveChanges(..., "edit") carries the same guarantee (same fix as fws/u6-apply-patch-wiring). --- 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 8cd7ea2a74..36acd436a1 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( @@ -568,6 +584,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 @@ -1540,6 +1572,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 f341d03117..6d3b2fa394 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1402,6 +1402,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 2fff9277d3db4f39aaadc6e5bd386755feb8842b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:34:22 +0800 Subject: [PATCH 36/73] fix(file-safety): handle async warning sinks and confine before mkdir Series alignment for the two review findings fixed on fws/u6-apply-patch-wiring: 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. 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 check need no directory to exist, so the order is now lock key, confinement, mkdir; the in-lock check on the resolved publish target stays. --- .../__tests__/safeWriteText.spec.ts | 32 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 20 +++++++++--- src/utils/__tests__/safeWriteJson.test.ts | 17 ++++++++++ src/utils/safeWriteJson.ts | 21 ++++++------ 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b658cca80a..351f13c63e 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..a9d8fd6164 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -150,14 +150,6 @@ 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 @@ -165,6 +157,8 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const lockKey = await resolveLockKey(absoluteFilePath) // Confinement, if the caller declared a scope, is checked BEFORE the lock is + // Also before the parent-directory creation below: an out-of-scope target with a + // missing parent would otherwise get a directory created outside confineTo. // 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 @@ -176,7 +170,16 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(lockKey), scopeRoot) } - // Acquire the lock before any file operations. If acquisition fails it throws + 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 + } + + + // immediately, and releaseLock stays a no-op so the finally block does not try // to release an unacquired lock. releaseLock = await acquireFileLock(lockKey) From 1b8187f643bb7839854d0b2324d681cf82bf0b92 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:45:06 +0800 Subject: [PATCH 37/73] fix(tools): ApplyDiffTool must observe the version its diff was built on Series alignment with fws/u6-apply-patch-wiring. The e2e apply_diff suite drives apply_diff without a prior read_file, so the only observation available at save time was the one DiffViewProvider.open() records for the preview. Now that the preview cannot authorize the save, the guarded save falls back to the unobserved-edit guard and the flow has no authorization of its own. ApplyDiffTool reads the file itself to compute the diff, so it observes that read (stat around the read, observe only when the file did not change underneath it - the same contract as ApplyPatchTool's hunk read). The save is authorized against the version the diff was computed against: unchanged file -> the save proceeds; file changed after the read -> the compare-and-swap rejects and the stale content is not published. --- src/core/tools/ApplyDiffTool.ts | 21 +++++++++ .../applyDiffTool.guardedWrite.spec.ts | 47 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index fa873b8e5d..49c416a687 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,27 @@ 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 52f5dc26ec949245522ade88335bac22a566bf6f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 16:52:48 +0800 Subject: [PATCH 38/73] 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 c994d08fae1fcc421885fe0f84941f35c13676ec Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:09:38 +0800 Subject: [PATCH 39/73] fix(integrations): never adopt an autosaved match for an unauthorized edit Series alignment with fws/u6-apply-patch-wiring. The autosave adoption branch accepted ANY GuardRejectedError when the buffer was clean. An "edit" save with no pre-open observation is rejected by the unobserved-edit guard - an authorization verdict, not a moved-token verdict - so if VS Code autosave had already written the buffer, adoption 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(). Also removes the dead committed flag in safeWriteText (declared and set, never read) and corrects the comment describing a backup-restore guard that 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 36acd436a1..0086234da6 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -616,6 +616,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 6d3b2fa394..51c204df52 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2339,6 +2339,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 6f12ae49d0701461eb0c0a936ad350066c09bd0b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 17:38:32 +0800 Subject: [PATCH 40/73] fix(tools): never refresh a model observation the tool read on a different version Series alignment with fws/u6-apply-patch-wiring and fws/u9-task-history-delete. ApplyDiffTool rewrites an observation only when none exists (its own hunk 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, preserving the completeness the model earned. A prior entry on an older version is left alone, so content built from a stale read cannot pass the save's compare-and-swap. Also repairs the interleaved confinement comment in safeWriteJson (and, where present, the confineTo doc) so it describes both checks: the declared path before the lock and before any parent-directory creation, and the resolved publish target again under the lock. --- src/core/tools/ApplyDiffTool.ts | 20 +++++--- .../applyDiffTool.guardedWrite.spec.ts | 47 +++++++++++++++++++ src/utils/safeWriteJson.ts | 16 +++---- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 49c416a687..98e642a6c8 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) }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index a9d8fd6164..8db2c32cb7 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -157,14 +157,14 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const lockKey = await resolveLockKey(absoluteFilePath) // Confinement, if the caller declared a scope, is checked BEFORE the lock is - // Also before the parent-directory creation below: an out-of-scope target with a - // missing parent would otherwise get a directory created outside confineTo. - // 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. + // taken and before the parent-directory creation below: an out-of-scope target + // with a missing parent would otherwise get a directory created outside + // confineTo, and proper-lockfile creates ${lockKey}.lock beside the lock key - + // a key that is the symlink referent, so a repository-planted link out of the + // scope would otherwise create a lock file 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) From 8b4d25b78a2d61c946dbb9702a49596466f314c9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 17:09:31 +0800 Subject: [PATCH 41/73] fix(editor): attribute an adopted publish to the version the preview saw Adoption only checked that the buffer was clean and the bytes on disk matched the intended content. Autosave produces exactly that state whenever it writes the modified side of a diff, so adoption could not tell 'only the token moved' apart from 'the guard was right to reject'. A stale update after an external writer moved the file, and an update over a target the model never read, were both reported as successes and the observation was refreshed onto the clobbered state - authorizing the model's next write. open() now records the on-disk version it stat-matched, and canAdoptPublishedContent adopts only when the save was authorized against that exact version: the placeholder case (open() wrote the file itself), or preOpenObservation.version === openToken. A preview over an unread target (preOpenObservation === null) is never adopted, for any write kind - the old exclusion covered 'edit' only. Also in this commit: - safeWriteText.integration.spec: the 'commit cannot replace the target' test never reached the commit rename - with backup:true it failed earlier at the backup copy of the directory (EISDIR). It is now it.each([false, true]) so backup:false exercises the real commit rename and backup:true keeps covering the backup-copy failure. - applyDiffTool.guardedWrite.spec: drop four unjustified 'as unknown as void' double assertions; tool.execute already returns void. Both adoption regressions are real pins: with the DiffViewProvider change stashed each new test resolves as a success instead of rejecting. Local: integrations + core/tools + services/file-safety + activate lanes 1384 passed / 17 skipped across 62 files; tsc --noEmit clean; eslint clean on all four files. --- .../applyDiffTool.guardedWrite.spec.ts | 8 +- src/integrations/editor/DiffViewProvider.ts | 60 +++++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 116 ++++++++++++++++++ .../safeWriteText.integration.spec.ts | 35 +++--- 4 files changed, 191 insertions(+), 28 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/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 0086234da6..93ae0601b8 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -104,6 +104,9 @@ export class DiffViewProvider { * caller never read, publishing stale content over the intervening change. */ private preOpenObservation: { version: string; complete: boolean } | null | undefined = undefined + // The on-disk version open() stat-matched, kept so accept-time adoption can + // verify the save was authorized against that exact version. + private openToken: string | undefined = undefined constructor( private cwd: string, @@ -168,10 +171,15 @@ export class DiffViewProvider { // 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) + if (preStats && postStats && versionTokenOfStat(preStats) === versionTokenOfStat(postStats)) { + // The version this preview stat-matched. Adoption at accept time is only + // sound when the save was authorized against exactly this version, so it is + // recorded whether or not the registry already had an observation for the + // path - the case it exists for is a prior read followed by an external + // writer moving the file before this preview ran. + this.openToken = versionTokenOfStat(preStats) + if (displayTask && !displayTask.observationRegistry.has(absolutePath)) { + displayTask.observationRegistry.observe(absolutePath, this.openToken, false) } } } else { @@ -539,6 +547,38 @@ export class DiffViewProvider { task.observationRegistry.observe(absolutePath, versionTokenOfStat(after), observation?.complete ?? false) return true } + /** + * Whether a byte-for-byte match on disk may be adopted as this save's publish. + * + * Adoption exists for one shape: the preview (or an autosave of it) already put + * the accepted content on disk, so the compare-and-swap rejects on a moved token + * even though the intended publish is satisfied. That reading is only sound when + * the save was authorized against the exact version open() stat-matched. + */ + private canAdoptPublishedContent(): boolean { + // A create placeholder is the file open() itself wrote, so a match is + // unambiguous and the CAS baseline is the placeholder token. + if (this.placeholderVersion !== undefined) { + return true + } + // open() snapshots the authorization as it stood before the preview. No + // snapshot means no preview ran, so there is no pairing to verify. + if (this.preOpenObservation === undefined) { + return true + } + // A preview over a target the model never read: the rejection is about + // authorization, not a moved token, for every write kind. Adopting the match + // would record an observation for content the model never read and authorize a + // later full-file replacement. + if (this.preOpenObservation === null) { + return false + } + // The caller's observation must still name the version the preview saw. If a + // different writer moved the file first, the guard was right to reject and the + // matching bytes are the clobber it warned about. + return this.openToken !== undefined && this.preOpenObservation.version === this.openToken + } + async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, @@ -616,11 +656,12 @@ 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) && + // The match must be attributable to THIS save: adoption exists for the + // autosave shape, where the preview already put the accepted bytes on disk and + // only the version token moved. A rejection caused by an external writer, or + // by a save that was never authorized at all, must stay a rejection - the + // matching bytes are what the clobber looks like from here. + this.canAdoptPublishedContent() && // 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. @@ -1578,6 +1619,7 @@ export class DiffViewProvider { this.snapshotPreviewTabs = [] this.placeholderVersion = undefined this.preOpenObservation = undefined + this.openToken = undefined } /** diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 51c204df52..17e18b0354 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2339,6 +2339,122 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) + it("does not adopt a match when an external writer moved the file before the preview", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/ext-writer.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/ext-writer.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/ext-writer.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 + + // The model read v1. A different writer then moved the file to v2, and the preview + // stat-matched v2. Autosave published the model's bytes over v2, so the disk now + // matches this save byte for byte - which is exactly what the clobber the guard + // rejected looks like from here. + const externalStats = { + 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 + mockTask.observationRegistry.observe( + `${mockCwd}/ext-writer.ts`, + versionTokenOfStat(previewStats), + true, + ) + vi.mocked(fs.stat).mockResolvedValue(externalStats) + vi.mocked(fs.readFile).mockResolvedValue("external bytes") + + await diffViewProvider.open("ext-writer.ts") + + // The autosave shape: a clean buffer holding exactly the accepted content. + diffViewProvider["activeDiffEditor"] = cleanEditor + + // The autosave shape: a clean buffer holding exactly the accepted content, and a + // disk token that moved past the version the save was authorized against. + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // No adoption: the observation is not moved onto the state that was clobbered, + // so the model's next write is not authorized by it. + expect( + mockTask.observationRegistry.get(`${mockCwd}/ext-writer.ts`)?.version, + ).toBe(versionTokenOfStat(previewStats)) + }) + + it("does not adopt a match for an update over a target the model never read", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/unread-target.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/unread-target.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/unread-target.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 + + // open() found no observation for the path, so the preview is the only read and + // the rejection is about authorization rather than a moved token. The exclusion + // used to cover an "edit" with no pre-open observation only, so an + // "update" over the same unread target was adopted as a success. + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("on disk bytes") + + await diffViewProvider.open("unread-target.ts") + + // The autosave shape: a clean buffer holding exactly the accepted content. + diffViewProvider["activeDiffEditor"] = cleanEditor + + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("File was only partially read") + + // The rejection is the outcome: under the old gate the byte match was adopted + // and saveChanges resolved as a success, granting the model a clean result for + // a write that never happened. + expect( + mockTask.observationRegistry.get(`${mockCwd}/unread-target.ts`)?.complete, + ).toBe(false) + }) + 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 diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts index 81d758349e..30a528ba8e 100644 --- a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -31,19 +31,24 @@ describe("safeWriteText against a real filesystem", () => { 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"]) - }) + it.each([false, true])( + "leaves the target bytes untouched when the publish fails (backup: %s)", + async (backup) => { + // backup:false reaches the commit rename, which cannot replace a directory with + // a regular file (EISDIR on POSIX, EPERM on Windows). backup:true fails earlier, + // at the copy of the directory made for the backup. Both must leave the target + // exactly as it was, with no residue. + 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 })).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"]) + }, + ) }) From 571dfbed7177dd288fa6e8a980829079a582d88e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 01:10:51 +0800 Subject: [PATCH 42/73] fix(u8): track placeholder ownership apart from the observation token Lifecycle Resource Cleanup: open() writes the new-file placeholder, but it recorded placeholderVersion only when the bracketing stat-matched read of the content succeeded. When that read failed or disagreed, the rejected-save and failed-open cleanups had no proof of ownership and left the file - and the directories made for it - on disk, where no later writer could tell it was theirs to remove. Ownership is now recorded from the stat open() already performs right after the write (the dev/ino of the file it created), independently of the token, and both cleanup paths share one check: the token when it is available, otherwise the recorded identity plus "still empty". A file that carries content is never removed on the fallback, so a writer that put bytes through the placeholder keeps them; the fallback can only ever act on a 0-byte file. Regression Evidence: a test drives the case the review asked for - open() fails after a peer changed the placeholder token - and asserts the peer's file and the created directories are untouched while open() rethrows its own failure. Two more cover the fallback: an empty placeholder with no token is removed with its directories, and a placeholder that gained content is kept. --- src/integrations/editor/DiffViewProvider.ts | 58 +++++++++--- .../editor/__tests__/DiffViewProvider.spec.ts | 94 +++++++++++++++++++ 2 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 93ae0601b8..fee563a1a6 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 (dev, ino) identity of the file open() created for a new file, taken from the + * stat it performs right after the write. placeholderVersion needs a stat-matched + * read of the content, and that can fail or disagree; ownership of the placeholder + * must not depend on it, or a rejected save leaves behind a file this call created + * that nobody can tell is theirs to remove. The identity is only ever used to remove + * a file that is still EMPTY, so the worst it can act on is a 0-byte file. + */ + private placeholderIdentity: { dev: bigint; ino: bigint } | 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 @@ -216,6 +225,10 @@ export class DiffViewProvider { const placeholderContent = await fs.readFile(absolutePath, "utf-8").catch(() => undefined) const placeholderPostStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) const placeholderToken = versionTokenOfStat(placeholderPreStats) + // Ownership first, independent of whether the token below can be trusted: + // this is the file open() just created, and its identity is what a later + // cleanup re-checks when no stat-matched token is available. + this.placeholderIdentity = { dev: placeholderPreStats.dev, ino: placeholderPreStats.ino } // Stat-matched (S2): the read is trusted only while the bracketing // stats agree and the content is exactly the empty placeholder. if ( @@ -282,6 +295,7 @@ export class DiffViewProvider { this.isEditing = false this.createdDirs = [] this.placeholderVersion = undefined + this.placeholderIdentity = undefined throw error } this.fadedOverlayController = new DecorationController("fadedOverlay", this.activeDiffEditor) @@ -369,16 +383,37 @@ export class DiffViewProvider { * path uses; the directories this call created then go, innermost first, stopping * at the first directory another writer populated in the meantime. */ + /** + * Whether the path still holds the placeholder this provider created. + * The stat-matched token is the strongest proof; when open() could not produce one + * (a failed or disagreeing bracketing read), ownership falls back to the identity + * captured at creation plus "still empty". A file that carries content is never + * removed on the fallback, so a peer that wrote through the placeholder keeps it. + */ + private async _placeholderStillOurs(absolutePath: string): Promise { + const stats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!stats) { + return false + } + if (this.placeholderVersion !== undefined) { + return versionTokenOfStat(stats) === this.placeholderVersion + } + const identity = this.placeholderIdentity + if (!identity) { + return false + } + return stats.dev === identity.dev && stats.ino === identity.ino && stats.size === 0n + } + private async undoPartialOpen(absolutePath: string): Promise { if (this.editType !== "create") { return } - const placeholderVersion = this.placeholderVersion + const ownsPlaceholder = this.placeholderVersion !== undefined || this.placeholderIdentity !== undefined try { await withFileLock(await resolveLockKey(absolutePath), async () => { - if (placeholderVersion) { - const stats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) - if (!stats || versionTokenOfStat(stats) !== placeholderVersion) { + if (ownsPlaceholder) { + if (!(await this._placeholderStillOurs(absolutePath))) { // Another writer owns the path now: leave their file alone. return } @@ -694,19 +729,17 @@ export class DiffViewProvider { if (updatedDocument.isDirty) { discardSucceeded = await this.revertDocument(updatedDocument) } - if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { + if ( + discardSucceeded && + this.editType === "create" && + (this.placeholderVersion !== undefined || this.placeholderIdentity !== undefined) + ) { // 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 - ) { + if (!(await this._placeholderStillOurs(absolutePath))) { return } let unlinked = false @@ -1618,6 +1651,7 @@ export class DiffViewProvider { this.userTouchedDiffEditor = false this.snapshotPreviewTabs = [] this.placeholderVersion = undefined + this.placeholderIdentity = undefined this.preOpenObservation = undefined this.openToken = undefined } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 17e18b0354..3970947634 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1641,6 +1641,100 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["placeholderVersion"]).toBeUndefined() }) + it("open() leaves a peer's file and the created directories when the placeholder token moved before the failed-open cleanup", async () => { + // The failed-open cleanup is data-preserving: when another writer owns the path by + // the time open() fails, undoPartialOpen must leave their file AND the directories + // that now hold it, while open() still rethrows its own failure. + const createdDirs = [`${mockCwd}/t3-peer`, `${mockCwd}/t3-peer/nested`] + vi.mocked(createDirectoriesForFile).mockResolvedValueOnce(createdDirs) + vi.mocked(vscode.window.showTextDocument).mockRejectedValue(new Error("Cannot open file")) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockReturnValue({ dispose: vi.fn() }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + // open() wrote the placeholder (first stat), then a peer replaced it before the + // bracketing post-stat: the tokens disagree, so no cleanup token is recorded. + const peerStats = { + isDirectory: () => false, + dev: BigInt(9), + ino: BigInt(11), + size: BigInt(64), + mtimeNs: BigInt(4_000_000_009n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(peerStats) + // The stat the cleanup itself performs sees the peer's file. + vi.mocked(fs.stat).mockResolvedValue(peerStats) + vi.mocked(fs.readFile).mockResolvedValue("peer content") + diffViewProvider.editType = "create" + + await expect(diffViewProvider.open("t3-peer/nested/new.ts")).rejects.toThrow(/Failed to/) + + expect(vi.mocked(fs.unlink)).not.toHaveBeenCalled() + expect(vi.mocked(fs.rmdir)).not.toHaveBeenCalled() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + expect(diffViewProvider["createdDirs"]).toEqual([]) + }) + + it("open() removes its own empty placeholder when no stat-matched token was recorded", async () => { + // The bracketing read failed, so placeholderVersion is undefined - but the file on + // disk is still the empty one this open() created. Ownership is provable from the + // identity captured at creation, so the placeholder must not be left behind. + const createdDirs = [`${mockCwd}/t3-notoken`, `${mockCwd}/t3-notoken/nested`] + vi.mocked(createDirectoriesForFile).mockResolvedValueOnce(createdDirs) + vi.mocked(vscode.window.showTextDocument).mockRejectedValue(new Error("Cannot open file")) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockReturnValue({ dispose: vi.fn() }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + const emptyOurs = { + isDirectory: () => false, + dev: BigInt(7), + ino: BigInt(8), + size: BigInt(0), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const movedAway = { ...emptyOurs, mtimeNs: BigInt(4_000_000_005n) } as unknown as BigIntStats + // pre/post stats disagree -> no token; the identity is still ours. + vi.mocked(fs.stat).mockResolvedValueOnce(emptyOurs).mockResolvedValueOnce(movedAway) + vi.mocked(fs.stat).mockResolvedValue(emptyOurs) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + + await expect(diffViewProvider.open("t3-notoken/nested/new.ts")).rejects.toThrow(/Failed to/) + + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(`${mockCwd}/t3-notoken/nested/new.ts`) + expect(vi.mocked(fs.rmdir)).toHaveBeenNthCalledWith(1, `${mockCwd}/t3-notoken/nested`) + expect(vi.mocked(fs.rmdir)).toHaveBeenNthCalledWith(2, `${mockCwd}/t3-notoken`) + expect(diffViewProvider["placeholderIdentity"]).toBeUndefined() + }) + + it("keeps a placeholder that gained content when no stat-matched token was recorded", async () => { + // The identity fallback may only ever remove an EMPTY file: a writer that put + // content through the same inode keeps it, and so do the directories holding it. + const createdDirs = [`${mockCwd}/t3-filled`, `${mockCwd}/t3-filled/nested`] + vi.mocked(createDirectoriesForFile).mockResolvedValueOnce(createdDirs) + vi.mocked(vscode.window.showTextDocument).mockRejectedValue(new Error("Cannot open file")) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockReturnValue({ dispose: vi.fn() }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + const emptyOurs = { + isDirectory: () => false, + dev: BigInt(7), + ino: BigInt(8), + size: BigInt(0), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const movedAway = { ...emptyOurs, mtimeNs: BigInt(4_000_000_005n) } as unknown as BigIntStats + const nowFilled = { ...emptyOurs, size: BigInt(24), mtimeNs: BigInt(4_000_000_007n) } as unknown as BigIntStats + vi.mocked(fs.stat).mockResolvedValueOnce(emptyOurs).mockResolvedValueOnce(movedAway) + vi.mocked(fs.stat).mockResolvedValue(nowFilled) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + + await expect(diffViewProvider.open("t3-filled/nested/new.ts")).rejects.toThrow(/Failed to/) + + expect(vi.mocked(fs.unlink)).not.toHaveBeenCalled() + expect(vi.mocked(fs.rmdir)).not.toHaveBeenCalled() + }) 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 From a5157589e2a077543a18f7136bafda8256b2df8d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 01:43:30 +0800 Subject: [PATCH 43/73] fix(file-safety): one lock key whether the parent directory exists yet Port of the #1917 fix into this unit: the unit branches are not cumulative, so this branch carries its own copy of safeWriteText's lock-key helper and the same defect. canonicalDirKey() canonicalized only the immediate parent and fell back to that literal spelling on ENOENT. A writer whose parent directory already existed canonicalized through a symlinked ancestor (or a Windows short name) while a writer racing to create the same directory got the literal path, so the two took different locks for one file and a read-modify-write lost one side. It now walks up to the nearest ancestor that exists, canonicalizes that, and re-joins the missing components; a realpath failure that is not ENOENT is propagated instead of being papered over with a key that may be wrong. Identifiers and comments are kept identical to the other units so the merge resolves trivially. --- .../__tests__/safeWriteText.spec.ts | 45 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 30 ++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 351f13c63e..e58001faf5 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1318,3 +1318,48 @@ describe("resolvePublishTarget", () => { expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), path.resolve(targetPath)) }) }) + +describe("resolveLockKey when the parent directory does not exist yet", () => { + beforeEach(() => mockDefaults()) + + it("canonicalizes through the nearest existing ancestor instead of the literal parent", async () => { + // Two writers must take ONE lock: the one whose parent directory is already there, + // and the one racing to create it. Resolving only the immediate parent and falling + // back to its literal spelling on ENOENT gave them different keys whenever an + // ancestor was a symlink or a Windows short name, so a read-modify-write under the + // advisory lock lost one side. + const aliasDir = path.resolve("/tmp/alias-parent") + const canonicalDir = path.resolve("/tmp/real-parent") + const nested = path.join(aliasDir, "nested") + const target = path.join(nested, "history_item.json") + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) + vi.mocked(fs.realpath).mockImplementation(async (p) => { + const s = String(p) + if (s === target || s === nested) { + throw Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + } + if (s === aliasDir) { + return canonicalDir + } + return s + }) + + expect(await resolveLockKey(target)).toBe(path.join(canonicalDir, "nested", "history_item.json")) + }) + + it("propagates a realpath failure that is not ENOENT instead of guessing a key", async () => { + // A realpath that fails for another reason says nothing about the canonical form; + // returning a literal key would silently put this writer on a different lock. + const target = path.join(path.resolve("/tmp/test-dir"), "history_item.json") + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) + // Not a link: the walk must reach the canonicalization step, which is where the + // non-ENOENT failure has to surface. + vi.mocked(fs.readlink).mockRejectedValue(Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })) + vi.mocked(fs.realpath).mockImplementation(async (p) => { + if (String(p) === target) return target + throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + }) + + await expect(resolveLockKey(target)).rejects.toThrow("EACCES") + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index f957ef0825..68f20eb979 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -191,8 +191,34 @@ function errorCode(error: unknown): string | undefined { 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)) + // Walk up to the nearest ancestor that EXISTS, canonicalize that, and re-join the + // components that are not there yet. Falling back to the unresolved spelling of the + // whole parent - what a single realpath(...).catch(() => dirPath) used to do - makes + // the key depend on whether the directory happens to exist: a writer whose parent is + // already there canonicalizes through a symlinked ancestor (or a short name) while a + // writer racing to create the same directory gets the literal spelling, so the two + // take different locks for one file and a read-modify-write loses one side. + // A realpath failure that is not "not there yet" says nothing about the canonical + // form, so it is propagated rather than papered over with a key that may be wrong. + let cursor = dirPath + const missing: string[] = [] + for (;;) { + const canonical = await fs.realpath(cursor).catch((error: unknown) => { + if (errorCode(error) === "ENOENT") return undefined + throw error + }) + if (canonical !== undefined) { + return path.join(canonical, ...missing.reverse(), path.basename(absoluteFilePath)) + } + missing.push(path.basename(cursor)) + const parent = path.dirname(cursor) + if (parent === cursor) { + // Every component up to the root is missing: there is nothing to canonicalize + // against, and the literal path is the only key left. + return path.join(dirPath, path.basename(absoluteFilePath)) + } + cursor = parent + } } /** From 01ad9b672e745090f7e25935a3ef16cd5bbe6f20 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 03:28:19 +0800 Subject: [PATCH 44/73] test(file-safety): a backup copy that fails part-way must not survive the write A defect reported on a sibling PR in the base repo: the backup destination is named before the copy runs, while the rollback cleanup keys off a flag that only becomes true once the copy succeeded - so a copyFile that fails after creating the destination leaves a half-written .bak beside the target forever. This branch does not have that shape. The whole backup creation (seed open with "wx", copyFile, chmod, fsync) is wrapped in a catch that unlinks the destination and clears backupPath before rethrowing, so the cleanup keys off the attempt rather than off the success. What was missing is coverage for the exact case the report describes: copyFile failing with the destination already created. Only the fsync-failure variant was tested. No production change. Negative control: deleting the cleanup unlink inside that catch fails exactly two tests - this one and the existing "a failed backup flush is reported and leaves no partial backup behind" - and restoring it leaves the file byte-identical. --- .../__tests__/safeWriteText.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index e58001faf5..8081a01730 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -519,6 +519,25 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) + it("a backup copy that fails part-way is removed, not left as a usable-looking backup", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The destination is seeded with openSync("wx") BEFORE any content exists at it, + // and the copy then fails part-way: the name is there, holding a partial copy of + // nothing usable. Cleanup has to key off the attempt, not off a copy that + // succeeded, or this file outlives the failed write beside the target. + vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("EIO"), { code: "EIO" })) + + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EIO") + + // Nothing was published, and the half-written copy is removed rather than left + // next to the target looking like a backup someone could restore. + 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 () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) From 6a4f5110d376fabd95c719b34c22257f44465327 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 06:50:15 +0800 Subject: [PATCH 45/73] fix(diff-view): put the save's post-publish cleanup inside the same teardown as a cancellation CodeRabbit's Lifecycle row on #1915/#1916: DiffViewProvider.saveChanges() published through guardedWrite() and then ran its post-publish cleanup outside the teardown state that revertChanges() serializes through. A cancellation or disposal that reached revertChanges() while the publish was awaiting tore the session down, and the save then closed the same diff views, reverted the same buffer and re-applied the auto-close preferences a second time. - New state: `teardownPasses` counts the teardown passes this provider actually ran (a caller that awaited an in-flight pass does not count). runTeardown() increments it; open() resets it, so a provider reused after a cancelled session does not report every later save as cancelled. - saveChanges() returns the "no save flow of its own" shape when a teardown began while the guarded publish was awaiting: that teardown owns the session. - The post-publish cleanup (listener disposal, buffer revert, closeAllDiffViews, auto-close decision, restorePreviewTabs) now runs inside runTeardown(), so a cancellation landing during it waits for it instead of closing the same tabs underneath it. Tests (DiffViewProvider.spec.ts, +103): - "saveChanges() skips its post-publish cleanup when a teardown began during the publish" - the cancellation is injected inside the mocked publish; asserts the save returns the empty shape and that applyEdit / closeAllDiffViews / restorePreviewTabs each ran exactly once. The publish implementation is restored in a finally: clearAllMocks() keeps queued implementations, and a leaked one cancels every later save in the file (observed: three later tests timed out). - "saveChanges() serializes its post-publish cleanup with a revertChanges() that lands during it" - the cleanup is gated; the revert is started while it is in flight and must not touch the document. Negative controls, blast radius as measured (conditions extended in place, parseable): - cancelled-check removed (teardownPasses > 0 && false) -> exactly 1 failed (the skip test). - teardown counter never incremented -> exactly 1 failed (the skip test). - runTeardown's in-flight guard removed -> 2 failed: the new serialization test AND the pre-existing "revertChanges() does not run a second teardown while one is already in flight"; that mutation removes the guarantee for both callers, so the wider blast radius is expected. All restores byte-identical; full spec 141 passed (139 before). src-level tsc (cwd=src) 0 = this branch's baseline with 0 error lines in the touched files; eslint --max-warnings=0 clean; eslint-suppressions.json byte-identical; diff 48/24 + 103/0. --- src/integrations/editor/DiffViewProvider.ts | 72 ++++++++---- .../editor/__tests__/DiffViewProvider.spec.ts | 103 ++++++++++++++++++ 2 files changed, 151 insertions(+), 24 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index fee563a1a6..696d441cc6 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -44,6 +44,12 @@ export class DiffViewProvider { private documentWasPinned = false private relPath?: string private teardownInFlight: Promise | undefined + // Counts the teardown passes this provider has actually run; a caller that awaited an + // in-flight pass does not count. A save uses it to tell its own post-publish cleanup apart + // from a teardown a cancellation or disposal started underneath it: once any teardown has + // begun the session is being taken down, and a second pass over the same buffers and tabs is + // duplicate cleanup. + private teardownPasses = 0 private newContent?: string private activeDiffEditor?: vscode.TextEditor private fadedOverlayController?: DecorationController @@ -129,6 +135,9 @@ export class DiffViewProvider { const fileExists = this.editType === "modify" const absolutePath = path.resolve(this.cwd, relPath) this.isEditing = true + // A new diff session may reuse this provider after a cancelled one, so the teardown marker + // starts clean: otherwise every later save would report itself cancelled without publishing. + this.teardownPasses = 0 // Snapshot the authorization as it stands before this preview touches the // registry; saveChanges(..., "edit") restores it below. @@ -788,35 +797,49 @@ export class DiffViewProvider { // 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) + // A cancellation or disposal that reached teardown while the guarded publish was awaiting + // already owns this session: that teardown closed the diff views and applied the auto-close + // preferences. Running this save's own post-publish cleanup on top of it would tear the same + // session down twice, so the save reports that it did not complete a save flow of its own. + if (this.teardownPasses > 0) { + return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } } - await this.closeAllDiffViews() + // The post-publish cleanup is a teardown pass like any other, so it goes through the same + // serialization revertChanges() uses: a cancellation that lands while it runs waits for it + // instead of closing the same tabs underneath it. + await this.runTeardown(async () => { + 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 (saveTask was resolved above - // for the guarded publish). - const saveState = await saveTask?.providerRef.deref()?.getState() + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset (saveTask was resolved above + // for the guarded publish). + const saveState = await saveTask?.providerRef.deref()?.getState() + + await this.keepOrCloseEditedFile( + absolutePath, + this.userTouchedDiffEditor, + saveState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + saveState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) - await this.keepOrCloseEditedFile( - absolutePath, - this.userTouchedDiffEditor, - saveState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, - saveState?.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() + }) - // Restore any preview tabs the diff evicted, reconstructing the user's - // prior not-yet-edited tab state. - await this.restorePreviewTabs() // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only @@ -1125,6 +1148,7 @@ export class DiffViewProvider { return } const inFlight = cleanup() + this.teardownPasses++ this.teardownInFlight = inFlight try { await inFlight diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 3970947634..34c05a5bf3 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3277,6 +3277,109 @@ describe("DiffViewProvider", () => { expect(applyEdit).toHaveBeenCalledTimes(1) }) + // The save's post-publish cleanup touches the same buffers and tabs a cancellation's + // teardown does, so it has to be one serialized pass - and a save whose session was torn + // down while the guarded publish was still awaiting must not run a cleanup of its own. + it("saveChanges() skips its post-publish cleanup when a teardown began during the publish", async () => { + // A cancellation can reach revertChanges() while the publish is awaiting. That teardown + // already closed the diff views and applied the auto-close preferences; a second pass + // would close the same tabs and re-run the same document revert. + const closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // The cancellation lands inside the guarded publish. The implementation has to be + // restored afterwards: clearAllMocks() drops call records but keeps queued + // implementations, and a leaked publish would cancel every later save in this file. + vi.mocked(safeWriteText).mockImplementation(async () => { + await diffViewProvider.revertChanges() + }) + + let result: Awaited> | undefined + try { + result = await diffViewProvider.saveChanges(false) + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(result).toEqual({ + newProblemsMessage: undefined, + userEdits: undefined, + finalContent: undefined, + }) + // Exactly one teardown ran - the cancellation's - and the save added no cleanup of its + // own on top of it. + expect(applyEdit).toHaveBeenCalledTimes(1) + expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + }) + + it("saveChanges() serializes its post-publish cleanup with a revertChanges() that lands during it", async () => { + // The cleanup closes the same tabs a cancellation would close. Going through the shared + // teardown state means the cancellation waits for the save instead of racing it. + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeAllDiffViews = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + const save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + + // The revert has not touched the document: it is waiting for the save's cleanup. + expect(applyEdit).not.toHaveBeenCalled() + + release() + await Promise.all([save, revert]) + + // One pass over the tabs, and the cancellation did not repeat the document revert. + expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + expect(applyEdit).not.toHaveBeenCalled() + }) + 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) From 0fbdf496c7b2b4a868c88df85f55af99299cc0be Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 08:03:33 +0800 Subject: [PATCH 46/73] fix(diff-view): let only the teardown that started a pass finalize the session Follow-up to 6a4f5110d, from the same Lifecycle row on #1916. That change serialized the save's post-publish cleanup, but revertChanges() still ran restorePreviewTabs() and reset() after its runTeardown() call even when it had only WAITED for someone else's pass. A cancellation that arrived while the save's cleanup was in flight therefore still restored the preview tabs and reset the provider a second time - the duplicate teardown the row is about. - runTeardown() now reports ownership: false when it awaited an in-flight pass, true when it ran one. - revertChanges() returns before restorePreviewTabs()/reset() when it did not own the pass. The pass that started the teardown owns the finalization; the waiting caller did not close the diff view either, so it must not clear state whose closing it does not own. Tests: - new "revertChanges() does not restore preview tabs or reset when it waited for another teardown" (DiffViewProvider.spec.ts +51): the save's cleanup is gated, the revert starts while it is in flight, and after both settle restorePreviewTabs ran exactly once and reset never. - new ObservationRegistry coverage (observationRegistry.spec.ts +27), the other half of the Regression Evidence row: forget() removes exactly the given path and reports true, leaving a neighbour's entry intact; forget() reports false for a path that was never observed and changes nothing. Negative controls, blast radius as measured (conditions extended in place, parseable): - finalization made unconditional (if (!ownedTeardown && false)) -> exactly 1 failed (the new test). - cancelled-check removed / teardown counter not incremented -> exactly 1 failed each. - runTeardown's in-flight guard removed -> 3 failed (the new ownership test, the save/revert serialization test and the pre-existing revert-vs-revert test). - forget() clearing the whole registry -> 2 failed; always returning false -> 1; never removing -> 1. All restores byte-identical. Specs: DiffViewProvider 142 passed, observationRegistry 13 passed. src-level tsc (cwd=src) 0 = this branch's baseline with 0 error lines in the touched files; eslint --max-warnings=0 clean; eslint-suppressions.json byte-identical; diff 14/3 + 51/0 + 27/0. NOT ported: the two safeWriteJson scope-resolution tests from d7c08e534. Ported verbatim they are fake in this branch's harness - with the non-ENOENT propagation removed at safeWriteJson.ts:79 and at :95 the suite still reported 0 failures, because the same EACCES reaches the caller through the publish path's own realpath of the scope, and the recorded realpath calls never show the ancestor the swallowed walk would have to consult. A test that cannot fail for the mutation it pins is worse than none, so the port was reverted (spec back to 26 passed) and the row's second half is reported instead of papered over. --- .../__tests__/observationRegistry.spec.ts | 27 ++++++++++ src/integrations/editor/DiffViewProvider.ts | 17 +++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 51 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts index a3c55ebc6d..f6d0f6cdb6 100644 --- a/src/core/task/__tests__/observationRegistry.spec.ts +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -105,4 +105,31 @@ describe("ObservationRegistry", () => { expect(obs.complete).toBe(false) }) }) + + it("forget() removes exactly the given path and reports it", () => { + // DiffViewProvider revokes the observation for the preview path it published. Removing + // a neighbour's entry would make that neighbour's later write look untouched. + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + reg.observe("/a/b/d.ts", "v2") + + expect(reg.forget("/a/b/c.ts")).toBe(true) + + expect(reg.get("/a/b/c.ts")).toBeUndefined() + expect(reg.get("/a/b/d.ts")?.version).toBe("v2") + expect(reg.size).toBe(1) + }) + + it("forget() reports false and changes nothing for a path that was never observed", () => { + // A cancellation can forget a path whose observation was already dropped. The caller + // distinguishes the two cases through this return value, so it must not throw and must + // leave the other entries alone. + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + + expect(reg.forget("/a/b/absent.ts")).toBe(false) + + expect(reg.get("/a/b/c.ts")?.version).toBe("v1") + expect(reg.size).toBe(1) + }) }) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 696d441cc6..d8bf78bca3 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1001,7 +1001,7 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.runTeardown(async () => { + const ownedTeardown = await this.runTeardown(async () => { if (!fileExists) { if (updatedDocument.isDirty) { await updatedDocument.save() @@ -1051,6 +1051,14 @@ export class DiffViewProvider { ) } }) + + if (!ownedTeardown) { + // Another teardown started first and already ran the pass over this session's buffers, + // tabs and preview state. Restoring preview tabs or resetting here again would be that + // same work a second time, and the reset would clear state whose closing this provider + // does not own. The pass that started the teardown finalizes the session. + return + } // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. await this.restorePreviewTabs() @@ -1142,10 +1150,12 @@ export class DiffViewProvider { * 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 { + private async runTeardown(cleanup: () => Promise): Promise { if (this.teardownInFlight !== undefined) { await this.teardownInFlight - return + // The pass belongs to whoever started it; that caller also owns the finalization + // steps (preview tabs, reset), so the caller is told it did not own this pass. + return false } const inFlight = cleanup() this.teardownPasses++ @@ -1155,6 +1165,7 @@ export class DiffViewProvider { } finally { this.teardownInFlight = undefined } + return true } // Stop tracking user activation of the target file. Called before any diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 34c05a5bf3..7c91184662 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3380,6 +3380,57 @@ describe("DiffViewProvider", () => { expect(applyEdit).not.toHaveBeenCalled() }) + // Finalization belongs to the teardown pass that started, not to every caller that waited + // for it: a second restorePreviewTabs()/reset() pair is the same cleanup running twice. + it("revertChanges() does not restore preview tabs or reset when it waited for another teardown", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeAllDiffViews = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + const save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + + // The revert waits for the save's pass instead of starting one of its own. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + await Promise.all([save, revert]) + + // The owning pass restored the preview tabs exactly once, and the waiting caller did + // not finalize the session a second time. + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).not.toHaveBeenCalled() + expect(applyEdit).not.toHaveBeenCalled() + }) + 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) From 2521ad9a7305900b26fa22a1da1cb60ebc7bc5c7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 08:13:31 +0800 Subject: [PATCH 47/73] test(file-safety): pin that an uncanonicalizable confined scope stops the write before the lock Second half of the Regression Evidence row on #1916. The confinement behaviour this PR adds (_resolveScopeRoot and its non-ENOENT propagation at safeWriteJson.ts:79) had no focused coverage in this branch, and the two tests that cover it in the sibling PR #1915 (d7c08e534) cannot be reused here: ported verbatim they stayed green with the propagation removed, because in this harness the publish-target resolution canonicalizes the scope itself and produces the same errno first. The pin therefore uses a seam this harness can count: the advisory lock. A confined write whose scope cannot be canonicalized must reject before any lock is taken and without staging anything. The target sits one directory deeper than the scope so resolving the publish target never canonicalizes the scope, which leaves the scope resolution as the only code that can fail on it. Two-step verification, both measured (this is the admission bar after the fake port): - seam probe (temporary, not kept): an ordinary confined write reaches the proper-lockfile mock (reached > 0), so "the lock was never taken" is a real assertion and not a vacuous one. - step 1, unmodified production: suite 27 passed, 4 skipped, 0 failures. - step 2, production broken by removing the non-ENOENT propagation at safeWriteJson.ts:79 (condition extended in place, parseable): exactly 1 failed - this test. Restored byte-identical. Not pinned, recorded rather than faked: the second propagation site (safeWriteJson.ts:95, the nearest-ancestor walk) could not be made to fail in this harness. A companion test for the walk case was written and measured - green at baseline, still green with :95 broken - so it was dropped instead of being kept. The walk site stays uncovered on this branch. src-level tsc (cwd=src) 0 = this branch baseline with 0 error lines in the touched file; eslint --max-warnings=0 clean; eslint-suppressions.json byte-identical; diff 43/0. --- src/utils/__tests__/safeWriteJson.test.ts | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index b19c7fd911..1e669a3faa 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -812,4 +812,47 @@ describe("safeWriteJson", () => { expect(entries).not.toContain("scope-missing-parent") expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) }) + + // Same ordering rule, different failure: the scope itself cannot be canonicalized. A + // non-ENOENT realpath errno has to stop the write before the advisory lock instead of + // falling back to a lexical scope and continuing. The lock is the seam this harness can + // count; the realpath call order is not observable here (measured separately). + test("refuses a confined write before the advisory lock when the scope cannot be canonicalized", async () => { + vi.resetModules() + // The target sits one directory deeper than the scope: resolving the publish target + // never canonicalizes the scope itself, so the only code that can fail on the scope is + // the scope resolution under test. + const scope = path.join(tempDir, "unresolvable-scope-order") + const inner = path.join(scope, "nested") + await fs.mkdir(scope) + await fs.mkdir(inner) + const target = path.join(inner, "mcp.json") + + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn(async () => { + throw new Error("lock taken after an uncanonicalizable scope (test)") + }) + vi.doMock("proper-lockfile", () => ({ ...realLockfile, lock: lockMockFn })) + const { safeWriteJson: lockedSafeWriteJson } = await import("../safeWriteJson") + const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (candidate) => { + if (String(candidate) === scope) { + throw Object.assign(new Error("EACCES: permission denied, realpath"), { code: "EACCES" }) + } + return String(candidate) + }) + + try { + await expect(lockedSafeWriteJson(target, { mcpServers: {} }, { confineTo: scope })).rejects.toThrow( + "EACCES: permission denied, realpath", + ) + expect(lockMockFn).not.toHaveBeenCalled() + const entries = await fs.readdir(tempDir) + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + } finally { + realpathSpy.mockRestore() + vi.doUnmock("proper-lockfile") + vi.resetModules() + } + }) + }) From 308178a22509bc0d9a3faa5c8b3c642170102b86 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 08:50:00 +0800 Subject: [PATCH 48/73] fix(diff-view): restore preview tabs for the pass that owns a rejected save, and pin the scope walk Clears the at-head review on #1916 (commit 2521ad9a7, review 5464483987, inline 4225550644) and the two remaining pre-merge warnings, in one commit so the branch resets its verdict once. 1. Inline 4225550644 - valid, verified against the code. The rejected-publish path (saveChanges catch, DiffViewProvider.ts:736-784) runs a teardown pass, closes its own diff view and rethrows; it never restored the preview tabs the diff evicted. With the ownership guard in revertChanges(), a concurrent revert that only waits no longer finalizes either, so nothing restored them. The path now captures the boolean from runTeardown() and restores the preview tabs only when it owns the pass, before rethrowing; reset() stays with the tool callers' error handling. Tests (3 new): the owning rejected save restores them once; a save whose revert waits restores them once in total; a save that joins an already-owned pass restores nothing and runs no cleanup of its own (reset() stubbed so closeOwnDiffView counts only the save's pass). Negative controls, measured here: restore removed -> 2 failed; ownership check dropped -> exactly 1 failed (the third test, which is what makes that check a real check). 2. Lifecycle Resource Cleanup row - the save flow now captures the boolean from its post-publish runTeardown() and returns the "did not complete" result instead of running diagnostics and reporting a completed save for a session another teardown finalized. Test: drives that branch directly by holding a pass open across the post-publish call, because every interleaving the public API can produce is already caught by the teardownPasses check above it - the comment says so in the test. Negative control: branch removed -> exactly 1 failed. 3. Regression Evidence row - the missing-scope confinement coverage CR asked for, using the shape CR suggested (target under the same existing ancestor but outside the missing scope). That shape is what makes the branch observable: propagating the errno and falling back to a higher ancestor now produce different errors, so a swallowed errno cannot hide behind an identical message. Tests (2 new): a non-ENOENT errno from the nearest-ancestor walk propagates instead of widening; a missing nested confineTo stays scoped to its reconstructed path. Negative controls, measured in this harness: propagation at safeWriteJson.ts:95 removed -> exactly 1 failed; propagation at :79 removed -> exactly 1 failed (the earlier lock-seam test); the reconstruction at :93 widened to the ancestor -> exactly 1 failed. Restores byte-identical. Verification: DiffViewProvider.spec 146 passed; safeWriteJson specs 33 passed / 4 skipped; observationRegistry 13; guardedWrite + applyDiffTool 54. src-level tsc (cwd=src) 0 = this branch baseline, 0 error lines in the touched files; eslint --max-warnings=0 clean; src/eslint-suppressions.json byte-identical. Diff 16/2 production, +192 and +57 tests. --- src/integrations/editor/DiffViewProvider.ts | 18 +- .../editor/__tests__/DiffViewProvider.spec.ts | 192 ++++++++++++++++++ src/utils/__tests__/safeWriteJson.test.ts | 57 ++++++ 3 files changed, 265 insertions(+), 2 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index d8bf78bca3..44049b58e4 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -733,7 +733,7 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.runTeardown(async () => { + const ownedTeardown = await this.runTeardown(async () => { let discardSucceeded = !updatedDocument.isDirty if (updatedDocument.isDirty) { discardSucceeded = await this.revertDocument(updatedDocument) @@ -778,6 +778,13 @@ export class DiffViewProvider { } await this.closeOwnDiffView(absolutePath) }) + if (ownedTeardown) { + // Opening the diff evicted any preview tab the file had. This path closes its own diff + // view and rethrows, so this pass is the only one that can put that preview state back; + // a caller that merely waited for it must not restore the tabs a second time. reset() + // stays with the tool caller's error handling, which owns the provider lifecycle. + await this.restorePreviewTabs() + } } catch { // cleanup is best-effort; the guard verdict below is the outcome } @@ -808,7 +815,7 @@ export class DiffViewProvider { // The post-publish cleanup is a teardown pass like any other, so it goes through the same // serialization revertChanges() uses: a cancellation that lands while it runs waits for it // instead of closing the same tabs underneath it. - await this.runTeardown(async () => { + const ownedTeardown = await this.runTeardown(async () => { this.disposeActiveEditorListener() this.cancelDeferredScroll() @@ -840,6 +847,13 @@ export class DiffViewProvider { await this.restorePreviewTabs() }) + if (!ownedTeardown) { + // A teardown that started first owns this session: it closed the diff views and applied the + // auto-close preferences. Reporting diagnostics and a completed-save result for a session + // that was torn down underneath this save would present a save that never finished. + return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } + } + // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 7c91184662..3f8b9b0092 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3431,6 +3431,198 @@ describe("DiffViewProvider", () => { expect(applyEdit).not.toHaveBeenCalled() }) + // A rejected publish closes its own diff view and rethrows, so the preview tabs the diff + // evicted are restored by that pass - and only by that pass. + it("saveChanges() restores preview tabs when a rejected publish tears the session down", async () => { + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const closeOwnDiffView = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // The publish is rejected, so the discard-only cleanup runs and the guard verdict is + // rethrown. Restore the publish mock afterwards: a queued rejection would fail every + // later save in this file. + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + try { + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("guard rejected for test") + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(closeOwnDiffView).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + }) + + it("saveChanges() restores preview tabs once when a revert waits for the rejected save", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeOwnDiffView = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + // The revert joins the rejected save's pass instead of starting its own. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await revert + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).not.toHaveBeenCalled() + }) + + // The other direction: the rejected save is the one that joins an existing pass. It then + // runs no cleanup of its own, and the pass that started the teardown restores the tabs once. + it("saveChanges() restores no preview tabs when a revert already owns the teardown", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeAllDiffViews = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const closeOwnDiffView = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + // reset() closes this provider's diff tab; stub it so closeOwnDiffView counts only the + // save's own cleanup pass. + diffViewProvider["reset"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // The revert starts its pass first and is held inside it. + const revert = diffViewProvider.revertChanges() + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await revert + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + // The save's cleanup never ran, so it restored nothing; the owning revert pass did the + // restoring exactly once. + expect(closeOwnDiffView).not.toHaveBeenCalled() + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + }) + + // Ownership, not the counter, is what decides the post-publish flow. Every interleaving the + // public API can produce is already caught by the counter check, so this drives the branch + // directly: a pass is held open across the save's post-publish call, and the save must report + // that it did not complete a save flow of its own. + it("saveChanges() reports no completed save when another teardown owns the post-publish pass", async () => { + let release: () => void = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + const closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + diffViewProvider["teardownInFlight"] = held + + const save = diffViewProvider.saveChanges(false) + await new Promise((resolve) => setImmediate(resolve)) + release() + const result = await save + + expect(result).toEqual({ + newProblemsMessage: undefined, + userEdits: undefined, + finalContent: undefined, + }) + expect(closeAllDiffViews).not.toHaveBeenCalled() + expect(restorePreviewTabs).not.toHaveBeenCalled() + }) + 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) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 1e669a3faa..3da3d223e7 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -855,4 +855,61 @@ describe("safeWriteJson", () => { } }) + + // The walk-up branch, pinned through the shape CR suggested: the target sits under the same + // existing ancestor but OUTSIDE the missing scope. That matters because the two outcomes then + // carry different errors - propagating the errno from the ancestor, versus falling back to a + // higher ancestor and rejecting on confinement - so a swallowed errno cannot hide behind an + // identical message (which is exactly what made the sibling PR's tests unable to pin this). + test("propagates a non-ENOENT errno from the scope walk instead of widening to a higher ancestor", async () => { + const real = path.join(tempDir, "walk-real") + await fs.mkdir(real) + const other = path.join(real, "other") + await fs.mkdir(other) + const scope = path.join(real, "nested") + const target = path.join(other, "mcp.json") + + const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (candidate) => { + const given = String(candidate) + if (given === scope) { + throw Object.assign(new Error("ENOENT: no such file or directory, realpath"), { code: "ENOENT" }) + } + if (given === real) { + throw Object.assign(new Error("ELOOP: too many symbolic links encountered, realpath"), { + code: "ELOOP", + }) + } + return given + }) + + try { + await expect(safeWriteJson(target, { mcpServers: {} }, { confineTo: scope })).rejects.toThrow( + "ELOOP: too many symbolic links encountered, realpath", + ) + const entries = await fs.readdir(other) + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + await expect(fs.readFile(path.join(other, "mcp.json"), "utf8")).rejects.toThrow() + } finally { + realpathSpy.mockRestore() + } + }) + + // Same shape for the reconstruction: the missing components have to be re-appended to the + // canonicalized ancestor. If the scope silently widened to the ancestor, this target would be + // inside it and the write would go through. + test("keeps a missing nested confineTo scoped to its reconstructed path when the ancestor resolves", async () => { + const real = path.join(tempDir, "reconstruct-real") + await fs.mkdir(real) + const other = path.join(real, "other") + await fs.mkdir(other) + const scope = path.join(real, "nested") + const target = path.join(other, "mcp.json") + + await expect(safeWriteJson(target, { mcpServers: {} }, { confineTo: scope })).rejects.toThrow( + /resolves outside the confined directory/, + ) + const entries = await fs.readdir(other) + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + await expect(fs.readFile(path.join(other, "mcp.json"), "utf8")).rejects.toThrow() + }) }) From 452a8e46d2ea49f903cda5e7f640a1b06b4b983e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 12:43:29 +0800 Subject: [PATCH 49/73] test(apply-diff): cover the two bracketing stats that can fail on their own The read that the diff is built from is bracketed by two fs.stat calls whose failures are swallowed on purpose: a stat that fails must not take the edit down with it, and it must not leave an authorization behind that the tool never earned. Neither branch had a test, so both were indistinguishable from a typo. Two tests, one per branch: the pre-read stat rejects, and the post-read stat rejects (the pre-read queued as a success so the branch under test is the one that fails). Each asserts that no observation was recorded, that the stat failure was not reported as a tool error, and that the edit still reached the guarded saveDirectly with its edit kind. Negative controls measured in this harness: removing the .catch on the pre-read stat -> 1 failed (the pre-read test); removing it on the post-read stat -> 1 failed (the post-read test). Each test is killed by removing exactly the branch it covers, and only its own test fails. Restores byte-identical. Baseline after the sweep: 309 passed across the five affected specs (9 in this file); tsc clean; eslint --max-warnings=0 clean; src/eslint-suppressions.json untouched. --- .../applyDiffTool.guardedWrite.spec.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index bf7bc4b520..3aaa0c11e1 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -290,4 +290,64 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { expect(observation?.version).toBe("1:2:9:9:9") expect(observation?.complete).toBe(true) }) + + it("keeps the edit alive when the stat before its read fails, and records no observation", async () => { + // The bracketing stats are best-effort: a stat that fails must not take the whole edit + // down with it. What it must not do is leave an authorization the tool never earned - + // with no pre-read stats there is no version to compare, so nothing is observed. + const stat = vi.mocked((await import("fs/promises")).default.stat) + stat.mockRejectedValueOnce(Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + // The tool did not treat a stat failure as its own failure... + expect(mockHandleError).not.toHaveBeenCalled() + // ...and the edit the model asked for still reached the guarded save. + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(stat).toHaveBeenCalledTimes(2) + }) + + it("keeps the edit alive when the stat after its read fails, and records no observation", async () => { + // The post-read stat is the other half of the bracket. If it fails the read cannot be + // shown to be stable, so no observation may be recorded - but the edit still runs and the + // stat failure is not reported as a tool error. + const stat = vi.mocked((await import("fs/promises")).default.stat) + // Queue the pre-read stat as a success and reject only the second one, so the branch this + // test is about - the post-read bracket - is the one that fails. + stat + .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats) + .mockRejectedValueOnce(Object.assign(new Error("EBUSY: resource busy"), { code: "EBUSY" })) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(stat).toHaveBeenCalledTimes(2) + }) }) From b73cec882dc8de7583919f213032a2dfe306af46 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 13:11:05 +0800 Subject: [PATCH 50/73] fix(read-file): stop indenting the read content inside the notice templates Three response templates put their notice first and then continued the template literal on an indented source line, so the first numbered line the model received was '\t\t\t\t1 | ...'. A SEARCH block copied from that view carries whitespace the file does not have, and the edit fails to match. The continuation now starts at column 0. The clipped-line response is covered by an assertion that the pushed view contains '\n1 | a' rather than an indented line. Negative control: re-indenting that one template line -> 1 failed (the clipped-read test). Two test-only gaps in the same review: - the win32 DACL restore warning is now asserted through a caller-supplied onWarning, not through console.warn. The default sink prints in both the routed and the un-routed code, so watching console.warn could not tell the two apart. Negative control: sending the warning straight to console.warn -> 1 failed (the new case). - the safety-net test now counts the unlinks of the staged .new_ file (safeWriteText cleans up the tempPath it was handed, then safeWriteJson runs its own net on the same path). Asserting only that one unlink touched .new_ passed even with the net removed, because the inner cleanup touches it too. Negative control: skipping the net's unlink -> 1 failed (expected 2 unlinks, got 1). Baseline after the sweep: 309 passed across the nine affected specs; tsc clean; eslint --max-warnings=0 clean on every touched file; src/eslint-suppressions.json untouched. --- src/core/tools/ReadFileTool.ts | 10 +++---- src/core/tools/__tests__/readFileTool.spec.ts | 5 +++- .../__tests__/safeWriteText.spec.ts | 28 +++++++++++++++++++ .../__tests__/safeWriteJson.lockKey.spec.ts | 7 +++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index ba1be7deaf..5048ed2b2c 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -321,8 +321,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = `IMPORTANT: File content truncated. Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. To read more: Use the read_file tool with offset=${nextOffset} and limit=${effectiveLimit}. - - ${result.content}` + +${result.content}` } else if (result.includedRanges.length > 0) { const rangeStr = result.includedRanges.map(([s, e]) => `${s}-${e}`).join(", ") output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)` @@ -350,8 +350,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = `IMPORTANT: File content truncated. Status: Showing lines ${startLine}-${endLine} of ${result.totalLines} total lines. To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. - - ${result.content}` + +${result.content}` if (result.hasClippedLines) { // The slice cut lines off and also clipped long lines inside it, so both // notices belong to the response. @@ -362,7 +362,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { // 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}` +${result.content}` } else if (result.returnedLines === 0) { output = "Note: File is empty" } diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 34d69ed4cc..90b914ffb9 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -1933,7 +1933,10 @@ describe("ReadFileTool", () => { 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") + // The notice is added on top of the read, it does not replace it - and the first + // numbered line must not arrive indented: a model that copies the view into a SEARCH + // block would be copying whitespace the file does not have. + expect(pushed).toContain("\n1 | a") }) it("native: a truncated slice that also clipped a line reports both notices", async () => { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8081a01730..1d8b16539f 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -770,6 +770,34 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) }) + it("win32 DACL: a failed restore reaches a caller-supplied onWarning", async () => { + // Watching console.warn cannot tell the routed warning from the default sink - the + // default sink also prints - so this case supplies its own sink and requires that the + // message lands there and NOT on console.warn. + 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(() => {}) + const onWarning = vi.fn() + + // icacls save succeeds, restore fails - the same shape as the default-sink case. + 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", onWarning }) + + expect(onWarning).toHaveBeenCalledTimes(1) + expect(onWarning.mock.calls[0][0]).toContain("could not be restored") + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + 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) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index beddaf9ea9..cfc79da1f2 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -175,6 +175,13 @@ it("does not log a cleanup error when the safety net finds the temp file already await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow("commit rename failed") + // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. + // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. + // Two unlinks of that path are expected: safeWriteText cleans up the tempPath it was + // handed, and then safeWriteJson runs its own safety net on the same path. Dropping the + // net would leave one, which is exactly what this checks. + const stagedUnlinks = unlinkSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes(".new_")) + expect(stagedUnlinks).toHaveLength(2) // Only the original failure is reported. expect(consoleError).toHaveBeenCalledTimes(1) From d8e7015e11b647c70eb8578e608b1c06b0e16255 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 13:24:20 +0800 Subject: [PATCH 51/73] test(safe-write): a warning sink that throws must not fail the committed write safeWriteText routes its post-commit notices through a warn() helper that guards the caller's sink: a callback that throws synchronously (a UI sink, a logger mid-restart) is caught and reported as 'onWarning callback failed', because the notice describes a publish that already committed and must not turn into a failed save. That fallback path had no test. Case added: the Windows DACL-restore warning is delivered to an onWarning that throws 'sink failed'. The write still resolves, the commit rename still happens exactly once, the sink is tried once, and the fallback reports 'onWarning callback failed: sink failed'. Negative control measured in this harness: catch the sink error but never report it -> 1 failed (the new case). A second mutant (removing the catch entirely) was attempted and the splice anchor did not match, so it was not measured - the claim here is only that the fallback reporting is covered, which is what the row asked for. Baseline after the sweep: 64 passed in this spec; tsc clean; eslint --max-warnings=0 clean; src/eslint-suppressions.json untouched. --- .../__tests__/safeWriteText.spec.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 1d8b16539f..89f03a492a 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -798,6 +798,39 @@ describe("safeWriteText", () => { warnSpy.mockRestore() }) + it("win32 DACL: a warning sink that throws does not fail the committed write", async () => { + // The notice describes a publish that already committed. A caller whose sink throws + // (a UI sink, a logger mid-restart) must not turn that into a failed save, and the + // delivery failure itself has to be reported rather than swallowed. + 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(() => {}) + const onWarning = vi.fn(() => { + throw new Error("sink failed") + }) + + // icacls save succeeds, restore fails - the same shape as the other two cases. + 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", onWarning }) + + // The write still committed. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) + // The sink was tried once, and its failure is reported through the fallback. + expect(onWarning).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("onWarning callback failed: sink failed")) + warnSpy.mockRestore() + }) + 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) From 0974ad5344ae7d76f8ff277e56b0b6ebf0763a46 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 15:10:57 +0800 Subject: [PATCH 52/73] fix(diff-view): finalize the session when a cancellation lands during a teardown it does not own Task.disposeOnce() can reach revertChanges() while saveChanges() owns its post-publish teardown. runTeardown() made the caller a waiter and returned false, revertChanges() then returned without finalizing, and the save's own pass closes the views and restores the tabs but never resets - the tool caller that owns the provider lifecycle may never come back after a disposal. The session was left with isEditing true and activeDiffEditor retained after the task was disposed. - runTeardown() records that a cancellation is waiting on the pass that owns the session. - saveChanges() reports no completed save when a cancellation landed during its post-publish pass, instead of running diagnostics and the EOL/patch tail against provider state (newContent, relPath) that the finalization clears. - revertChanges() finalizes the session once the owning pass has returned, if that pass left it open. The pass that ran the tabs still owns the preview-tab restoration, so no tab or document work runs twice. Two existing tests asserted the old contract (a waiting revertChanges() never finalizes); they now assert exactly one finalization. Same defect, open on four units at their current heads - #1915 (U6), #1916 (U8), #1917 (U9), #1918 (U7) - and DiffViewProvider.ts already carries four distinct blobs across those heads (82b58576d / 44049b58e / 854569b96 / 15f032a12). Authored once here, in the unit that owns the save-gate teardown; port it in the declared merge order U1 U2 U3 U4 U5 U8 U6 U7 U9 instead of patching each unit's copy. Red first: 2 failed | 146 passed with only the new tests added. Green: 148 passed in that spec, integrations/editor 156 passed, guardedWrite + applyDiffTool.guardedWrite + observationRegistry 69 passed. Negative controls, each restoring the production file byte-exactly in a finally: removing the waiter finalization -> 4 failed; removing the save's cancellation bail-out -> 1 failed; not recording the cancellation in runTeardown -> 1 failed. tsc --noEmit 0 errors; eslint --max-warnings=0 clean on both files; src/eslint-suppressions.json untouched. --- src/integrations/editor/DiffViewProvider.ts | 34 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 153 +++++++++++++++++- 2 files changed, 176 insertions(+), 11 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 44049b58e4..309b7f38dd 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -44,6 +44,12 @@ export class DiffViewProvider { private documentWasPinned = false private relPath?: string private teardownInFlight: Promise | undefined + /** + * A cancellation (revertChanges) that arrived while another pass already owned this + * session. The owning pass reads it when its cleanup returns, so a save does not run + * diagnostics or read provider state that a cancellation is closing underneath it. + */ + private teardownCancellationRequested = false // Counts the teardown passes this provider has actually run; a caller that awaited an // in-flight pass does not count. A save uses it to tell its own post-publish cleanup apart // from a teardown a cancellation or disposal started underneath it: once any teardown has @@ -854,6 +860,15 @@ export class DiffViewProvider { return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } } + if (this.teardownCancellationRequested) { + // A cancellation or disposal reached revertChanges() while this save owned the post-publish + // pass. The publish is on disk, but the session is closing: the waiting caller finalizes + // it, and diagnostics or the EOL/patch tail below read provider state (newContent, + // relPath) that the finalization clears. Report no completed save instead. + this.teardownCancellationRequested = false + return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } + } + // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only @@ -1068,9 +1083,15 @@ export class DiffViewProvider { if (!ownedTeardown) { // Another teardown started first and already ran the pass over this session's buffers, - // tabs and preview state. Restoring preview tabs or resetting here again would be that - // same work a second time, and the reset would clear state whose closing this provider - // does not own. The pass that started the teardown finalizes the session. + // tabs and preview state, so restoring the preview tabs here would be that same work a + // second time. Finalizing the session is still this caller's responsibility when the + // owning pass is one that never resets - a save's post-publish cleanup, or a rejected + // save's discard cleanup, whose reset belongs to a tool caller that a cancellation or + // disposal may never let come back. The owning pass has returned, so the session is + // closed once and only once. + if (this.isEditing || this.activeDiffEditor !== undefined) { + await this.reset() + } return } // Restore any preview tabs the diff evicted, reconstructing the user's @@ -1166,11 +1187,18 @@ export class DiffViewProvider { */ private async runTeardown(cleanup: () => Promise): Promise { if (this.teardownInFlight !== undefined) { + // Record the cancellation before waiting: the pass that owns the session has to know a + // cancellation is waiting on it, and the waiting caller must not leave the session + // mid-edit if that pass turns out not to finalize it. + this.teardownCancellationRequested = true await this.teardownInFlight // The pass belongs to whoever started it; that caller also owns the finalization // steps (preview tabs, reset), so the caller is told it did not own this pass. return false } + // A pass starts clean: a cancellation recorded for an earlier pass must not leak into + // this one. + this.teardownCancellationRequested = false const inFlight = cleanup() this.teardownPasses++ this.teardownInFlight = inFlight diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 3f8b9b0092..98306889a0 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3380,9 +3380,10 @@ describe("DiffViewProvider", () => { expect(applyEdit).not.toHaveBeenCalled() }) - // Finalization belongs to the teardown pass that started, not to every caller that waited - // for it: a second restorePreviewTabs()/reset() pair is the same cleanup running twice. - it("revertChanges() does not restore preview tabs or reset when it waited for another teardown", async () => { + // The pass that started the teardown owns the tab work, so a waiting caller must not + // restore the preview tabs twice. It still has to leave the session closed: the owning + // pass here is a save's post-publish cleanup, which never resets on its own. + it("revertChanges() restores no preview tabs twice but finalizes the session the owning pass left open", async () => { let release: () => void = () => {} const gate = new Promise((resolve) => { release = resolve @@ -3424,15 +3425,148 @@ describe("DiffViewProvider", () => { release() await Promise.all([save, revert]) - // The owning pass restored the preview tabs exactly once, and the waiting caller did - // not finalize the session a second time. + // The owning pass restored the preview tabs exactly once, and the waiting caller + // finalized the session exactly once - the pass that ran the tabs does not reset, so + // the cancellation that waited on it closes the session instead of leaving it active. expect(restorePreviewTabs).toHaveBeenCalledTimes(1) - expect(reset).not.toHaveBeenCalled() + expect(reset).toHaveBeenCalledTimes(1) expect(applyEdit).not.toHaveBeenCalled() }) // A rejected publish closes its own diff view and rethrows, so the preview tabs the diff // evicted are restored by that pass - and only by that pass. +// The pre-merge row this covers: a cancellation or disposal that lands while the save owns + // its post-publish teardown used to leave the provider mid-edit. The save's pass closes the + // views and restores the tabs but never resets, the waiting revertChanges() returned without + // finalizing, and Task.disposeOnce() only awaits the reversion promise - so isEditing and the + // active editor survived the task that owned them. + it("saveChanges() finalizes the session when a cancellation lands during its post-publish teardown", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeAllDiffViews = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + const listener = { dispose: vi.fn() } + diffViewProvider["activeEditorListener"] = listener + diffViewProvider["deferredScrollTimer"] = setTimeout(() => {}, 10_000) + + const save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + + // Task.disposeOnce() reaches revertChanges() while the save's pass owns the session. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + const [saveResult] = await Promise.all([save, revert]) + + // The session is closed: no edit flag, no retained editor, listeners and timer gone. + expect(diffViewProvider.isEditing).toBe(false) + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + expect(listener.dispose).toHaveBeenCalledTimes(1) + expect(diffViewProvider["activeEditorListener"]).toBeUndefined() + expect(diffViewProvider["deferredScrollTimer"]).toBeUndefined() + + // One pass over the tabs and buffers: the waiting cancellation repeated neither the + // document revert nor the preview-tab restoration. + expect(applyEdit).not.toHaveBeenCalled() + expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + + // A session a cancellation is closing reports no completed save, so no caller can present + // diagnostics or user-edit output for provider state that is already gone. + expect(saveResult).toEqual({ + newProblemsMessage: undefined, + userEdits: undefined, + finalContent: undefined, + }) + }) + + // The same gap on the other teardown owner: a rejected publish's discard cleanup never resets + // (the tool caller's error handling owns that), so a cancellation that only waited for it left + // the session mid-edit with nobody finalizing it. + it("a cancellation that waits for a rejected save's discard cleanup still finalizes the session", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeOwnDiffView = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // Restore the publish mock afterwards: a queued rejection would fail every later save. + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + type SaveResult = Awaited> + let settled: PromiseSettledResult[] | undefined + try { + const save: Promise = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + settled = await Promise.allSettled([save, revert]) + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + // The guard verdict still surfaces... + expect(settled?.[0].status).toBe("rejected") + // ...and the session that cancellation was waiting on is finalized. + expect(diffViewProvider.isEditing).toBe(false) + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + // The waiting cancellation did not repeat the document revert. + expect(applyEdit).not.toHaveBeenCalled() + }) + it("saveChanges() restores preview tabs when a rejected publish tears the session down", async () => { const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) const closeOwnDiffView = vi.fn().mockResolvedValue(undefined) @@ -3467,7 +3601,10 @@ describe("DiffViewProvider", () => { expect(restorePreviewTabs).toHaveBeenCalledTimes(1) }) - it("saveChanges() restores preview tabs once when a revert waits for the rejected save", async () => { + // The rejected save's discard cleanup restores the tabs once, and it never resets - the + // tool caller's error handling owns that reset. A cancellation that only waited for that + // pass still has to leave the session closed, so it performs the finalization. + it("saveChanges() restores preview tabs once and the waiting revert finalizes the session it left open", async () => { let release: () => void = () => {} const gate = new Promise((resolve) => { release = resolve @@ -3517,7 +3654,7 @@ describe("DiffViewProvider", () => { } expect(restorePreviewTabs).toHaveBeenCalledTimes(1) - expect(reset).not.toHaveBeenCalled() + expect(reset).toHaveBeenCalledTimes(1) }) // The other direction: the rejected save is the one that joins an existing pass. It then From 318f0114bcd4c9d0340e9f9c3c5a0083a22a0e8d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 15:35:31 +0800 Subject: [PATCH 53/73] test(diff-view): put the rejected-publish comment back above the test it documents The teardown-finalization tests were inserted between that comment and its own test, and the first inserted comment line lost its indentation. Comment placement only: no assertion changed, the spec still reports 148 passed and eslint is clean on the file. --- src/integrations/editor/__tests__/DiffViewProvider.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 98306889a0..213ab3525f 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3433,9 +3433,7 @@ describe("DiffViewProvider", () => { expect(applyEdit).not.toHaveBeenCalled() }) - // A rejected publish closes its own diff view and rethrows, so the preview tabs the diff - // evicted are restored by that pass - and only by that pass. -// The pre-merge row this covers: a cancellation or disposal that lands while the save owns + // The pre-merge row this covers: a cancellation or disposal that lands while the save owns // its post-publish teardown used to leave the provider mid-edit. The save's pass closes the // views and restores the tabs but never resets, the waiting revertChanges() returned without // finalizing, and Task.disposeOnce() only awaits the reversion promise - so isEditing and the @@ -3567,6 +3565,8 @@ describe("DiffViewProvider", () => { expect(applyEdit).not.toHaveBeenCalled() }) + // A rejected publish closes its own diff view and rethrows, so the preview tabs the diff + // evicted are restored by that pass - and only by that pass. it("saveChanges() restores preview tabs when a rejected publish tears the session down", async () => { const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) const closeOwnDiffView = vi.fn().mockResolvedValue(undefined) From 8757698c85434d4a4596b6793c13036dd13df722 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 20:59:48 +0800 Subject: [PATCH 54/73] fix(file-safety): never abandon the backup seed descriptor when its close fails Pre-merge row (Lifecycle Resource Cleanup, warning) on this PR: the backup path opens `seedFd` to create the destination with its mode fixed at creation time and closes it on the next line, with no protected ownership. If that close fails, the descriptor is abandoned - the backup handler unlinks the partial copy and rethrows, and nothing else ever closes the handle. The `backupFd` a few lines below already sits in a `try/finally`, which is what makes the gap specific to the new seed descriptor. - `seedFd` is now tracked across the whole backup block and cleared only once the close has succeeded, so the handler knows whether this call still owns it. - The backup failure handler retries the release through `_closeDescriptor` before reporting. A second close failure is logged rather than thrown, so the error this write fails with stays the backup failure that explains it. - A descriptor that was already released is not closed again: another open may have taken that number in the meantime, and closing it would pull a live handle out from under its new owner. Tests: a new case forces the seed close to fail and asserts the close is attempted twice, the retry's failure is reported, every descriptor this write opened was handed to `closeSync`, and the partial backup is still removed. The existing part-way-copy case now also asserts the seed descriptor is closed exactly once, which is what kills the "never release ownership" mutant. Red first: 1 failed | 64 passed. Green: 68 passed across `services/file-safety`. Negative controls, one-to-one: removing the retry -> exactly the new test fails; never clearing ownership -> exactly the part-way-copy test fails. Production file restored byte-exactly after the mutants and re-run at 68 passed. `tsc --noEmit` 0 errors; eslint `--max-warnings=0` clean on both files; `src/eslint-suppressions.json` untouched. --- .../__tests__/safeWriteText.spec.ts | 53 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 27 +++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 89f03a492a..c48ccb8e86 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -522,7 +522,12 @@ describe("safeWriteText", () => { it("a backup copy that fails part-way is removed, not left as a usable-looking backup", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) + // Distinct fds so the seed descriptor can be told apart from the backup's fsync + // handle and the staging temp: the seed is the first open of the backup path. + let backupOpens = 0 + vi.mocked(fsSync.openSync).mockImplementation((target: unknown) => { + return String(target).includes("safeWriteText.bak_") ? (backupOpens++ === 0 ? 7 : 8) : 1 + }) // The destination is seeded with openSync("wx") BEFORE any content exists at it, // and the copy then fails part-way: the name is there, holding a partial copy of // nothing usable. Cleanup has to key off the attempt, not off a copy that @@ -536,6 +541,52 @@ describe("safeWriteText", () => { expect(fs.rename).not.toHaveBeenCalled() expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + + // The seed descriptor was released successfully before the copy failed, so the failure + // handler must not close it a second time: another open may already own that number, and + // closing it here would pull a live handle out from under its new owner. + expect(vi.mocked(fsSync.closeSync).mock.calls.filter((call) => Number(call[0]) === 7)).toHaveLength(1) + }) + + it("a failing seed-descriptor close is retried and never leaves the descriptor unreleased", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // The backup destination is seeded with openSync("wx") and released immediately. Give + // every open its own fd so the closes can be matched to the opens, and make the + // seed's close fail: at that moment the descriptor is still owned by this call, while + // the error this write ends up reporting is the backup failure. + let backupOpens = 0 + const openedFds: number[] = [] + vi.mocked(fsSync.openSync).mockImplementation((target: unknown) => { + const fd = String(target).includes("safeWriteText.bak_") ? (backupOpens++ === 0 ? 7 : 8) : 1 + openedFds.push(fd) + return fd + }) + const seedFd = 7 + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.mocked(fsSync.closeSync).mockImplementation((fd: unknown) => { + if (Number(fd) === seedFd) { + throw Object.assign(new Error("EBADF"), { code: "EBADF" }) + } + }) + + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EBADF") + + // The close is attempted again by the backup failure handler rather than the descriptor + // being abandoned, and that retry's failure is reported without replacing the error the + // write fails with. + const seedAttempts = vi.mocked(fsSync.closeSync).mock.calls.filter((call) => Number(call[0]) === seedFd).length + expect(seedAttempts).toBe(2) + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("backup seed descriptor"), expect.anything()) + // Every descriptor this write opened was handed to closeSync: no handle outlives the + // failed backup. + for (const fd of openedFds) { + expect(vi.mocked(fsSync.closeSync).mock.calls.some((call) => Number(call[0]) === fd)).toBe(true) + } + // The partial backup and the staging temp are still cleaned up. + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + consoleError.mockRestore() }) it("backup:true when target does not exist: no backup created, just commit", async () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 68f20eb979..3d2cff7c73 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -105,6 +105,20 @@ function _stagingDir(dir: string): string { return sd } +/** + * Release a descriptor this call still owns. A close that fails must not become the error a + * failed write reports - the operation's own failure is the one that explains what happened - + * but the descriptor must not be abandoned either, so the caller retries through here and the + * second failure is reported. + */ +function _closeDescriptor(fd: number, label: string): void { + try { + fsSync.closeSync(fd) + } catch (error: unknown) { + console.error(`Failed to close the ${label}:`, error) + } +} + function _fsyncFile(fd: number): void { fsSync.fsyncSync(fd) } @@ -467,6 +481,7 @@ export async function safeWriteText( // 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. + let seedFd: number | undefined 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 @@ -477,8 +492,11 @@ export async function safeWriteText( // 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) + seedFd = fsSync.openSync(backupPath, "wx", 0o600) fsSync.closeSync(seedFd) + // Owned no longer: only after the close succeeds is the descriptor somebody + // else's problem. If the close throws, the handler below still holds it. + seedFd = undefined 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 @@ -490,6 +508,13 @@ export async function safeWriteText( fsSync.closeSync(backupFd) } } catch (backupError: unknown) { + if (seedFd !== undefined) { + // The seed descriptor is still owned here: its close failed, or an error landed + // between the open and that close. Retry the release before reporting, and let a + // second close failure be logged rather than replace the backup failure this + // handler is about to rethrow. + _closeDescriptor(seedFd, `backup seed descriptor for ${backupPath}`) + } // 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(() => {}) From 226555d4c59bb26e1a4461798433b1d7b54f16fb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 21:36:45 +0800 Subject: [PATCH 55/73] fix(diff-view): one caller owns the session's finalization, by claim rather than by reading state Port of fc1c87f0c (the same fix landed on the U6 branch) to this unit. This unit carries the state-proxy shape: after a shared teardown pass, a caller that had only waited for it decided whether to close the session by reading isEditing / activeDiffEditor. That state is exactly what the finalization changes, so two cancellations that both waited on a pass which never resets - a save's post-publish cleanup, or a rejected save's discard cleanup - each saw the session still open and each ran reset(). Measured before the change here: reset called twice for one session. - `finalizeSession` makes the finalization an owned, serialized step: the first caller claims it and runs it, a caller arriving while it runs joins that attempt, a caller arriving afterwards does nothing. The pass owner and waiting callers both go through it. - The claim is per session and `open()` clears it, so a reused provider is finalizable again. - `reset()` also records the claim. Stated honestly that assignment is an invariant guard, not a reachable branch: reset clears relPath and activeDiffEditor, and every path that could reach finalizeSession returns early when either is missing, so no test can reach it. It is kept so that "a reset means the session is closed" holds at the reset itself, including against a caller that passed the entry check before those fields were cleared. Tests ported with the fix: two cancellations against one in-flight pass finalize the session once; a second cancellation after the first closed the session does not finalize again; a reset outside the teardown paths is respected. Red first with the tests ported and the production change absent: 2 failed | 149 passed. Green: 151 passed. Negative controls: claim removed entirely -> 4 failed (the two new cases plus the two pre-existing finalization tests); in-flight dedup without the completed-claim check -> exactly the sequential-cancellation test fails; the guard inside reset() removed -> suite stays green at 151, reported with the reachability argument above rather than labelled equivalent. Production file restored byte-exactly after the mutants (c54de4fa325e) and re-run at 151 passed. `tsc --noEmit` 0 errors (this unit's baseline); eslint `--max-warnings=0` clean on both files; `src/eslint-suppressions.json` untouched. --- src/integrations/editor/DiffViewProvider.ts | 60 +++++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 115 ++++++++++++++++++ 2 files changed, 165 insertions(+), 10 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 309b7f38dd..66c873f41e 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -50,6 +50,15 @@ export class DiffViewProvider { * diagnostics or read provider state that a cancellation is closing underneath it. */ private teardownCancellationRequested = false + /** + * Whether this edit session has been closed (its reset has been claimed). A caller that + * waited for someone else's teardown cannot decide this by reading provider state: isEditing + * and activeDiffEditor are exactly what the finalization changes, so two callers that both + * waited on a pass which never resets would each see the session still open and each close it. + * The claim is what makes "exactly one of us finalizes" a fact rather than a race. + */ + private sessionFinalizationClaimed = false + private finalizationInFlight: Promise | undefined // Counts the teardown passes this provider has actually run; a caller that awaited an // in-flight pass does not count. A save uses it to tell its own post-publish cleanup apart // from a teardown a cancellation or disposal started underneath it: once any teardown has @@ -144,6 +153,10 @@ export class DiffViewProvider { // A new diff session may reuse this provider after a cancelled one, so the teardown marker // starts clean: otherwise every later save would report itself cancelled without publishing. this.teardownPasses = 0 + // A new session has to be finalizable: a provider that was reset once would otherwise + // report its finalization as already claimed. + this.sessionFinalizationClaimed = false + this.finalizationInFlight = undefined // Snapshot the authorization as it stands before this preview touches the // registry; saveChanges(..., "edit") restores it below. @@ -1084,22 +1097,21 @@ export class DiffViewProvider { if (!ownedTeardown) { // Another teardown started first and already ran the pass over this session's buffers, // tabs and preview state, so restoring the preview tabs here would be that same work a - // second time. Finalizing the session is still this caller's responsibility when the - // owning pass is one that never resets - a save's post-publish cleanup, or a rejected - // save's discard cleanup, whose reset belongs to a tool caller that a cancellation or - // disposal may never let come back. The owning pass has returned, so the session is - // closed once and only once. - if (this.isEditing || this.activeDiffEditor !== undefined) { - await this.reset() - } + // second time. Finalizing the session is still owed when the owning pass is one that + // never resets - a save's post-publish cleanup, or a rejected save's discard cleanup, + // whose reset belongs to a tool caller that a cancellation or disposal may never let + // come back. Whoever gets here first claims it; a second waiter joins the claim + // instead of running a second reset. + await this.finalizeSession(() => this.reset()) return } // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. await this.restorePreviewTabs() - // Edit is done. - await this.reset() + // Edit is done. The owner of the pass claims the finalization too, so a caller that + // waited on this pass cannot also close the session behind it. + await this.finalizeSession(() => this.reset()) } private async closeAllDiffViews(): Promise { @@ -1210,6 +1222,31 @@ export class DiffViewProvider { return true } + /** + * Run the session's finalization exactly once. The first caller claims it and runs it; + * anyone who arrives while it is running awaits that attempt rather than starting their own, + * and anyone who arrives after it is done does nothing. A pass that owns its own teardown and + * a caller that merely waited for one go through here, which is what keeps "only one caller + * owns reset()" true regardless of how many cancellations piled onto the same pass. + */ + private async finalizeSession(finalize: () => Promise): Promise { + if (this.sessionFinalizationClaimed) { + return + } + if (this.finalizationInFlight) { + await this.finalizationInFlight + return + } + const inFlight = finalize() + this.finalizationInFlight = inFlight + try { + await inFlight + this.sessionFinalizationClaimed = true + } finally { + this.finalizationInFlight = 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 { @@ -1692,6 +1729,9 @@ export class DiffViewProvider { } async reset(): Promise { + // A reset closes the session whoever calls it through, so the finalization is claimed + // here as well: a teardown waiter that arrives afterwards must not run a second one. + this.sessionFinalizationClaimed = true // Dispose touch listeners and cancel any pending deferred scroll BEFORE any // async editor manipulation. closeAllDiffViews() awaits tab-close operations, // so leaving listeners/timers live across that await could let a stale handler diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 213ab3525f..d8c474a626 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3657,6 +3657,121 @@ describe("DiffViewProvider", () => { expect(reset).toHaveBeenCalledTimes(1) }) + it("revertChanges() finalizes the session once when two cancellations wait on the same pass", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeOwnDiffView = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + // Two cancellations - a tool caller and a disposal, say - both join the pass that is + // already running. Neither owns it, and the owning pass never finalizes the session, + // so exactly one of them may close it. + const first = diffViewProvider.revertChanges() + const second = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await Promise.all([first, second]) + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + }) + + it("revertChanges() does not finalize a session a second time after the first cancellation closed it", async () => { + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // Two cancellations one after the other: the first owns its pass and closes the session, + // the second starts a pass of its own and must still not close the session a second time. + await diffViewProvider.revertChanges() + await diffViewProvider.revertChanges() + + expect(restorePreviewTabs).toHaveBeenCalledTimes(2) + expect(reset).toHaveBeenCalledTimes(1) + }) + + it("revertChanges() does not finalize a session that a direct reset already closed", async () => { + const realReset = diffViewProvider["reset"].bind(diffViewProvider) + let resetCalls = 0 + diffViewProvider["reset"] = async () => { + resetCalls++ + await realReset() + } + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // A reset outside the teardown paths closes the session for everyone, so the claim has to + // come from the reset itself and not only from the caller that went through the helper. + await diffViewProvider["reset"]() + await diffViewProvider.revertChanges() + + expect(resetCalls).toBe(1) + }) + // The other direction: the rejected save is the one that joins an existing pass. It then // runs no cleanup of its own, and the pass that started the teardown restores the tabs once. it("saveChanges() restores no preview tabs when a revert already owns the teardown", async () => { From 6f64792d59a04129480e494e9c4845fc02d37901 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 02:12:35 +0800 Subject: [PATCH 56/73] fix(safe-write): a write that declares no scope replaces the link instead of writing through it Port of 72f5ae1d8 (landed on the U6 branch) to this unit. safeWriteJson resolved an existing symlink and published to the referent while confineTo is optional and no production caller (McpHub) passes one, so a workspace that plants .roo/mcp.json as a link to a file outside the workspace had its MCP update land outside the workspace. - safeWriteText gains publishOverLink: the commit renames onto the requested path itself rather than onto the referent of a link found there. - safeWriteJson resolves the publish target only for a caller that declared a confinement scope and passes publishOverLink for everyone else, restoring the rename-over-link behavior. The merge read still goes through the link, and the lock key still resolves through it, so an alias and its referent keep coordinating on one lock. Contract changes to existing tests, stated explicitly - these pinned the behavior the row calls a defect, none was deleted: "waits for the peer instead of rejecting, and locks the referent" keeps its lock-key assertion and now records that an unconfined write performs no publish-target resolution and lands on the link; "releases the lock when the resolution under the lock rejects", "stages the temp file beside the symlink referent and commits onto it" and "acquires the lock on the resolved referent, not the caller alias" now declare confineTo, because the properties they prove (in-lock resolution, publishing THROUGH a link) exist only on the confined path. Measured on this unit: red first with the tests ported and the production change absent -> 2 failed | 32 passed | 4 skipped. Green -> 102 passed | 4 skipped across the safeWriteJson, lock-key and safeWriteText specs. Negative controls, each restored byte-exactly: resolution made unconditional again -> 2 failed (the new test plus the adapted lock-key test); publishOverLink ignored in the primitive -> the same 2 failed. Post-restore 99 passed | 4 skipped on the three-spec subset. tsc --noEmit and eslint are verified after the last edit of this unit's stack; src/eslint-suppressions.json untouched. --- src/services/file-safety/safeWriteText.ts | 18 ++++++- .../__tests__/safeWriteJson.lockKey.spec.ts | 15 ++++-- src/utils/__tests__/safeWriteJson.test.ts | 51 ++++++++++++++++++- src/utils/safeWriteJson.ts | 11 +++- 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 3d2cff7c73..1d6f4e9010 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -42,6 +42,18 @@ export interface SafeWriteTextOptions { * already written data to a temp file via a custom stream. */ tempPath?: string + + /** + * Publish onto the requested path itself instead of onto the referent of a symlink found + * there. + * + * Following a link is only safe once somebody has decided that the referent is an + * acceptable destination - by declaring a confinement scope, or by checking the resolved path + * some other way. A caller that did neither must keep the rename-over-link behavior: a link + * planted inside the workspace would otherwise move the write to a file outside it, and the + * caller would never learn that its bytes landed somewhere else. + */ + publishOverLink?: boolean } /** @@ -268,8 +280,10 @@ export async function safeWriteText( ): Promise { const absoluteFilePath = path.resolve(filePath) - // Resolve the symlink referent (see resolvePublishTarget). - const targetPath = await resolvePublishTarget(absoluteFilePath) + // Resolve the symlink referent (see resolvePublishTarget), unless the caller asked to + // replace the link instead of writing through it. The link is then the target, so a referent + // outside the caller's view cannot receive these bytes. + const targetPath = options?.publishOverLink ? absoluteFilePath : await resolvePublishTarget(absoluteFilePath) const dirPath = path.dirname(targetPath) // Ensure parent directory exists (mirrors safeWriteJson behaviour). diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index cfc79da1f2..f3e380616b 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -100,8 +100,14 @@ describe("safeWriteJson lock key under a peer commit", () => { // 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" }) + // A write that declares no confinement scope no longer resolves the publish target at all, + // so the two post-lock resolutions this used to record are gone; the lock key still comes + // from the referent, which is what the test is about. + expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "lstat", "lstat"]) + // The bytes replaced the link rather than travelling through it to the referent: nobody + // authorized the referent here, because no scope was declared. + expect(JSON.parse(await fs.readFile(currentLink, "utf8"))).toEqual({ id: "task-1" }) + await expect(fs.readFile(referent, "utf8")).rejects.toThrow() }) it("releases the lock when the resolution under the lock rejects", async () => { @@ -133,7 +139,10 @@ describe("safeWriteJson lock key under a peer commit", () => { } }) - await expect(safeWriteJson(currentLink, { id: "task-1" })).rejects.toThrow(enoent) + // The in-lock resolution only happens for a caller that declared a scope, so the scope is + // declared here: what this test pins is that a rejection inside the protected block still + // releases the lock, and that path is reached through the confined resolution. + await expect(safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir })).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. diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 3da3d223e7..8c23f8f63e 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -562,7 +562,9 @@ describe("safeWriteJson", () => { target === callerPath ? referentPath : String(target), ) - await safeWriteJson(callerPath, { after: true }) + // The scope is declared because this test is about publishing THROUGH a link: a write with + // no declared scope replaces the link instead, which is covered by its own test below. + await safeWriteJson(callerPath, { after: true }, { confineTo: tempDir }) // 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])) @@ -636,7 +638,9 @@ describe("safeWriteJson", () => { // Capture the compromise + release-failure logs. const consoleErrorSpy = vi.spyOn(console, "error") try { - await mockedSafeWriteJson(callerPath, { added: true }, { merge: mergeFn }) + // confineTo is declared so this write still publishes through the link: the lock key + // follows the link either way, and this test is about that key and the merge it orders. + await mockedSafeWriteJson(callerPath, { added: true }, { merge: mergeFn, confineTo: tempDir }) // The lock was keyed by the resolved referent — every alias shares it. expect(lockMock).toHaveBeenCalledTimes(1) @@ -729,6 +733,49 @@ describe("safeWriteJson", () => { }, ) + test("does not follow an untrusted symlink for a write that declares no confinement scope", async () => { + const projectDir = path.join(tempDir, "project-untrusted") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "outside-untrusted.json") + await fsSyncActual.promises.writeFile(outside, JSON.stringify({ secret: "original" }), "utf8") + const projectConfig = path.join(projectDir, "mcp.json") + // Real symlinks are unavailable on the Windows lane, so the link is simulated the way the + // lock-key spec does: the configured path reports itself a link to a file outside the + // workspace. Nothing here passes confineTo, which is how production writes (McpHub) call + // this primitive - the caller picked projectConfig from the workspace and said no more. + const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (target) => { + if (String(target) === projectConfig) throw enoent + return fsSyncActual.promises.realpath(String(target)) + }) + const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation(async (target) => { + if (String(target) === projectConfig) { + return { isSymbolicLink: () => true } as unknown as import("fs").Stats + } + return fsSyncActual.promises.lstat(String(target)) + }) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async (target) => { + if (String(target) === projectConfig) return outside + throw new Error("not a symbolic link") + }) + try { + await safeWriteJson(projectConfig, { mcpServers: { local: { url: "http://localhost" } } }) + } finally { + realpathSpy.mockRestore() + lstatSpy.mockRestore() + readlinkSpy.mockRestore() + } + + // The outside file is unchanged: a workspace file that happens to be a link to somewhere + // else must not receive a write the caller asked for inside the workspace. + expect(JSON.parse(await fsSyncActual.promises.readFile(outside, "utf8"))).toEqual({ secret: "original" }) + // The link itself was replaced, which is what this primitive did before it resolved links. + expect(JSON.parse(await fsSyncActual.promises.readFile(projectConfig, "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 () => { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 8db2c32cb7..9bac97b92d 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -192,7 +192,13 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // 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) + // Only a caller that declared a confinement scope has looked at the referent, so only such + // a caller may publish through it. Following a link for every writer lets a workspace file + // that happens to be a symlink - .roo/mcp.json pointing at a file outside the workspace - + // redirect a default write (McpHub passes no scope) onto a file nobody authorized, which is + // the boundary the confined callers exist to protect. A default write therefore replaces the + // link itself, as this primitive did before it resolved links at all. + resolvedTargetPath = options?.confineTo ? await resolvePublishTarget(absoluteFilePath) : 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 @@ -241,6 +247,9 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const textOptions: SafeWriteTextOptions = { tempPath: actualTempNewFilePath, backup: true, + // A write with no declared scope replaces the link rather than writing through it, so the + // publish must not re-resolve the path it is handed below. + publishOverLink: options?.confineTo === undefined, } await safeWriteText(resolvedTargetPath, "", textOptions) From 0cd2537619ffed9dea21cd05d0d063373a8770cf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 04:38:41 +0800 Subject: [PATCH 57/73] fix(diff-view): the rejected-save pass owns the session through preview restoration The row: "The rejected-save path can run teardown twice after cancellation. In saveChanges(), the first runTeardown() ends at closeOwnDiff() (lines 755-799), but restorePreviewTabs() runs afterward" - "Keep the rejected-save cleanup owned as one teardown pass through preview restoration. Move restorePreviewTabs() into the runTeardown() cleanup callback, or otherwise keep teardownInFlight occupied until preview restoration completes." The preview restoration now runs as the last step inside the cleanup callback instead of after runTeardown() returned. The callback only ever runs for the pass that owns the session, so the ownership rule the old "if (ownedTeardown)" guard expressed is unchanged - what changes is that teardownInFlight stays occupied while the tabs are being put back, which is the window in which a cancellation used to start a second pass over a session the first pass had already closed. The other two restoration sites are not the same defect and are left alone: the post-publish cleanup already restores inside its callback (DiffViewProvider.ts:866), and revertChanges() restores after its pass but its session work is guarded by the finalization claim, which the existing "revertChanges() finalizes the session once when two cancellations wait on the same pass" test pins. Measured on this unit: red first with the test added and the production change absent -> the new test fails with "expected \"vi.fn()\" to be called 1 times, but got 2 times" on restorePreviewTabs (3 failed | 150 passed, the other two being the known @roo-code/types junction artifact, see the U6 branch's note on DEFAULT_WRITE_DELAY_MS). Green -> 2 failed | 151 passed, i.e. only the junction artifact remains. Negative control, restored byte-exactly (f88530308072): moving the restoration back outside the pass turns exactly the new test red again (3 failed | 150 passed). tsc --noEmit and eslint are verified after the last edit of this unit's stack; src/eslint-suppressions.json untouched. --- src/integrations/editor/DiffViewProvider.ts | 11 ++-- .../editor/__tests__/DiffViewProvider.spec.ts | 60 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 66c873f41e..aa1357797e 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -796,14 +796,15 @@ export class DiffViewProvider { }) } await this.closeOwnDiffView(absolutePath) - }) - if (ownedTeardown) { // Opening the diff evicted any preview tab the file had. This path closes its own diff // view and rethrows, so this pass is the only one that can put that preview state back; - // a caller that merely waited for it must not restore the tabs a second time. reset() - // stays with the tool caller's error handling, which owns the provider lifecycle. + // a caller that merely waited for it must not restore the tabs a second time. It runs + // INSIDE the pass rather than after it: the pass is what owns the session, and a + // cancellation that arrives while the tabs are being put back has to wait for that + // pass instead of starting a second teardown over the same tabs. reset() stays with + // the tool caller's error handling, which owns the provider lifecycle. await this.restorePreviewTabs() - } + }) } catch { // cleanup is best-effort; the guard verdict below is the outcome } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index baf8ebc480..09189752d8 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3664,6 +3664,66 @@ describe("DiffViewProvider", () => { expect(reset).toHaveBeenCalledTimes(1) }) + + // The row: the rejected save's pass ended at closeOwnDiffView(), and the preview tabs were + // restored after the pass was over. A cancellation that landed in that window started a + // second teardown pass over a session the first pass had already closed, and restored the + // preview tabs a second time. The pass now owns the session through preview restoration. + it("saveChanges() keeps the teardown pass occupied while it restores the preview tabs of a rejected save", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let restoreEntered = false + const restorePreviewTabs = vi.fn().mockImplementation(async () => { + restoreEntered = true + await gate + }) + const closeOwnDiffView = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + while (!restoreEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + // The cancellation arrives while preview restoration is still running - the + // window in which the pass used to have already ended. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await revert + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + // One pass, one preview restoration, one finalization. + expect(closeOwnDiffView).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + }) + it("revertChanges() finalizes the session once when two cancellations wait on the same pass", async () => { let release: () => void = () => {} const gate = new Promise((resolve) => { From 04dceaf30395be290f1deddf9ddf018321ade49c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 06:07:53 +0800 Subject: [PATCH 58/73] fix(safe-write): an existing Windows target is not published over under an unknown or unverifiable ACL The row: "For an existing Windows target, do not commit when DACL capture fails. If DACL restoration fails after commit, restore a restrictive verified ACL before returning, or otherwise fail safely without exposing the committed content. Add an inte[gration case]." Both halves are implemented, and the second one is shaped by a measurement rather than by taste. Measured on this host with the real binary: icacls /save /T exits 0 and writes a 208-byte dump, while icacls /restore exits 1300 with "Not all privileges or groups referenced are assigned to the caller" for every pairing. So refusing to publish when the CAPTURE fails costs nothing on a working machine - capture is the half that works - whereas refusing when the RESTORE fails would break every publish in a throwaway temp directory, which is what the code being replaced said out loud. That is why the restore branch narrows instead of failing, and fails only when the narrowing cannot be verified: - Capture failure on an existing target: DaclCaptureError, thrown before the commit rename. The target keeps its content and its ACL; the staging file and this write's staging directory are removed with the refusal, and the partial dump is unlinked as before. - Restore failure after commit: icacls /inheritance:r /grant:r :F, then VERIFIED by reading the DACL back - the read must show the grant and must not show an inherited (I) entry. Verified: the write succeeds and the caller is warned that the rights were narrowed rather than restored. Not verified (grant failed, read failed, or an inherited entry survives): the retained backup is renamed back onto the target, so the committed bytes never stay at the path under an ACL nobody authorized, and DaclRestoreError is thrown. Contract changes to existing tests, stated explicitly - these pinned the warn-and-publish behavior the row calls a defect, and none was deleted: "win32 DACL failure falls back to plain rename (never fails the write)" is now "an existing target whose DACL could not be captured is not committed over"; "reports that access rights may change when the DACL cannot be saved" and "a partial dump left by a failed save is removed and never restored" now assert the refusal, the absence of a rename, and the staging cleanup; "a throwing onWarning does not abort the write" and "a rejecting async onWarning ..." keep their exact point (warning delivery cannot fail a save) and reach a warning through the restore-failure path instead of the capture path, because capture no longer warns. The three restore-reporting tests keep their assertions and only learn that the narrowing runs after the failed restore. Measured on this unit: the production change alone turned 8 existing tests red (5 capture, 3 restore) - the contract changes above - and after re-pointing plus three new tests the spec is 68 passed; the integration spec is 4 passed including the new real-icacls case, which asserts the invariant on the file a caller is left holding (published bytes readable, and if the DACL shows no inherited entry then it shows the verified grant) or the rollback (old bytes back at the target). Negative controls, each restored byte-exactly (ae4436f3191c): skipping the narrowing -> exactly the 3 new tests red; capture back to warn-and-proceed -> exactly the 3 re-pointed capture tests red; making the verification vacuous (accept any non-empty read-back) -> exactly the inherited-ACE test red. tsc --noEmit reports 0 errors in the files this change touches (the 74 in this merged tree are the packages/types build-artifact issue recorded on the U6 branch); eslint on the touched files is clean and the full lint stays exit 0; src/eslint-suppressions.json untouched. --- .../safeWriteText.integration.spec.ts | 52 ++++- .../__tests__/safeWriteText.spec.ts | 212 ++++++++++++++---- src/services/file-safety/safeWriteText.ts | 116 ++++++++-- 3 files changed, 324 insertions(+), 56 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts index 30a528ba8e..efc8937d52 100644 --- a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -1,8 +1,9 @@ +import { execFileSync } from "child_process" import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import { safeWriteText } from "../safeWriteText" +import { DaclRestoreError, 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 @@ -22,9 +23,10 @@ describe("safeWriteText against a real filesystem", () => { 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. + // No platform override: the real platform's own durability and ACL steps run. A failed + // icacls restore in a throwaway temp directory no longer fails the publish on its own - + // the committed file is narrowed to a verified restrictive ACL instead - so the publish + // still lands. await safeWriteText(targetPath, "new bytes", { backup: true }) expect(await fs.readFile(targetPath, "utf8")).toBe("new bytes") @@ -51,4 +53,46 @@ describe("safeWriteText against a real filesystem", () => { expect(await fs.readdir(dir)).toEqual(["target-dir"]) }, ) + + // The row asks for an integration case: what a real icacls leaves on a real file when the + // saved DACL cannot be reapplied. A throwaway temp directory is exactly where that happens - + // measured on this host, icacls /restore exits 1300 for every pairing - so this asserts the + // invariant in terms of the file a caller is left holding, not in terms of icacls calls. + // The capture half of the row needs no integration case here because a real icacls /save of + // an existing file succeeds on this host (exit 0, measured); the refusal it triggers when it + // does not is pinned by the unit tests. + it.skipIf(process.platform !== "win32")( + "a real publish never leaves the target under an ACL the caller cannot use", + async () => { + const targetPath = path.join(dir, "acl-target.txt") + await fs.writeFile(targetPath, "old bytes") + const identity = os.userInfo().username + + let published = true + try { + await safeWriteText(targetPath, "new bytes", { backup: true }) + } catch (error: unknown) { + // The only acceptable failure here is the ACL one, and it must have rolled back. + expect(error).toBeInstanceOf(DaclRestoreError) + published = false + expect(await fs.readFile(targetPath, "utf8")).toBe("old bytes") + } + + if (!published) { + return + } + + // The caller can still read what it wrote: the narrowing grants the current user full + // control, and a restored DACL keeps whatever the file had before. + expect(await fs.readFile(targetPath, "utf8")).toBe("new bytes") + const acl = execFileSync("icacls", [targetPath], { encoding: "utf8" }) + if (!acl.includes("(I)")) { + // A narrowing was accepted instead of a restore; then it must be the verified one. + expect(acl.toLowerCase()).toContain(identity.toLowerCase()) + expect(acl).toContain("(F)") + } + // No dump or backup residue survives either outcome. + expect((await fs.readdir(dir)).filter((n) => !n.endsWith(".txt"))).toEqual([]) + }, + ) }) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index c48ccb8e86..c6c91e8110 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -2,9 +2,12 @@ import * as fs from "fs/promises" import * as fsSync from "fs" import { execFile } from "child_process" import type { ChildProcess } from "child_process" +import * as os from "os" import * as path from "path" import { + DaclCaptureError, + DaclRestoreError, PostCommitDurabilityError, resolveLockKey, safeWriteText, @@ -289,10 +292,20 @@ describe("safeWriteText", () => { 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 - }) + // Only the restore fails, so a warning is delivered and the restrictive fallback + // (drop inheritance, grant the current user, read the DACL back) verifies. + vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + callback(null, `${argv[0]} ${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") + } + return fakeChild + }) const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) await expect( @@ -638,7 +651,7 @@ describe("safeWriteText", () => { expect(execFile).not.toHaveBeenCalled() }) - it("win32 DACL failure falls back to plain rename (never fails the write)", async () => { + it("win32: an existing target whose DACL could not be captured is not committed over", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -648,11 +661,14 @@ describe("safeWriteText", () => { return fakeChild }) - await safeWriteText(targetPath, "data", { platform: "win32" }) + // Contract change, stated explicitly: this test pinned the warn-and-publish behavior + // the review row calls a security defect. Publishing over a target whose access rights + // are unknown hands the file whatever the destination directory grants, decided by a + // helper that failed. The write is now refused before the commit rename. + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(DaclCaptureError) - // 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 + // Nothing was committed, and no restore was attempted from a dump that never saved. + expect(fs.rename).not.toHaveBeenCalled() expect(execFile).toHaveBeenCalledTimes(1) }) @@ -666,12 +682,13 @@ describe("safeWriteText", () => { }) 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) + // The refusal carries what the warning used to say: the caller learns why the save did + // not happen, and the target keeps the ACL it already had. + await expect( + safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }), + ).rejects.toThrow(DaclCaptureError) + expect(fs.rename).not.toHaveBeenCalled() + expect(warnings).toHaveLength(0) }) it("win32: reports when the target cannot be checked for DACL preservation", async () => { @@ -698,10 +715,20 @@ describe("safeWriteText", () => { 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 - }) + // Only the restore fails, so a warning is delivered and the restrictive fallback + // (drop inheritance, grant the current user, read the DACL back) verifies. + vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + callback(null, `${argv[0]} ${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") + } + return fakeChild + }) await expect( safeWriteText(targetPath, "data", { @@ -727,16 +754,99 @@ describe("safeWriteText", () => { return fakeChild }) - await safeWriteText(targetPath, "data", { platform: "win32" }) + // Contract change (see the capture test above): the write is refused, not committed. + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(DaclCaptureError) - // 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) + // Nothing was committed and no restore was attempted from a failed dump. + expect(fs.rename).not.toHaveBeenCalled() 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 + // the dump path (possibly partially created by icacls) was unlinked, and so was the + // staging file this write had already created - a refused publish leaves nothing behind. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + + // The row's second half: "If DACL restoration fails after commit, restore a restrictive + // verified ACL before returning, or otherwise fail safely without exposing the committed + // content." These three cases are the two branches and the verification step. + it("win32 DACL: a failed restore narrows the committed file and reports the narrowing", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const warnings: string[] = [] + vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + callback(null, `${argv[0]} ${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") + } + return fakeChild + }) + + await expect(safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) })).resolves.toBeUndefined() + + // The narrowing was actually applied to the committed file, not just reported. + const grant = vi.mocked(execFile).mock.calls.find(([, a]) => (a as unknown as string[])[1] === "/inheritance:r") + expect(grant).toBeDefined() + expect((grant?.[1] as unknown as string[]) ?? []).toEqual([ + targetPath, + "/inheritance:r", + "/grant:r", + `${os.userInfo().username}:F`, + ]) + expect(warnings.filter((m) => m.includes("narrowed to the current user's full control and verified"))).toHaveLength(1) + }) + + it("win32 DACL: a restore failure the narrowing cannot recover from rolls the commit back", 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) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore" || argv[1] === "/inheritance:r") { + callback(new Error("icacls failed"), "", "") + } else { + callback(null, "", "") + } + return fakeChild + }) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "win32" })).rejects.toThrow(DaclRestoreError) + + // The committed bytes do not stay at the target under an unknown ACL: the retained + // backup is renamed back, so the second rename is the rollback. + expect(fs.rename).toHaveBeenCalledTimes(2) + expect(fs.rename).toHaveBeenLastCalledWith(expect.stringContaining("safeWriteText.bak"), targetPath) + }) + + it("win32 DACL: a narrowing that still shows an inherited ACE is not accepted as verified", 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) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + // The grant "succeeded" but the DACL still carries an inherited entry. + callback(null, `${argv[0]} ${os.userInfo().username}:(F) DESKTOP\\users:(I)(RX)`, "") + } else { + callback(null, "", "") + } + return fakeChild + }) + + // "Verified" is what the row asks for; a grant that leaves inherited access is not it. + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(DaclRestoreError) }) it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { @@ -797,11 +907,19 @@ describe("safeWriteText", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) // 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"), "", "") + // Only the restore fails. The restrictive fallback - drop the inherited ACEs, grant + // the current user sole full control, read the DACL back - succeeds, which is the + // alternative to failing the publish that the review row asks for. + vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + // The verification read-back: an explicit grant and no (I) inherited marker. + callback(null, `${argv[0]} ${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") } return fakeChild }) @@ -832,11 +950,19 @@ describe("safeWriteText", () => { const onWarning = vi.fn() // icacls save succeeds, restore fails - the same shape as the default-sink case. - 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"), "", "") + // Only the restore fails. The restrictive fallback - drop the inherited ACEs, grant + // the current user sole full control, read the DACL back - succeeds, which is the + // alternative to failing the publish that the review row asks for. + vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + // The verification read-back: an explicit grant and no (I) inherited marker. + callback(null, `${argv[0]} ${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") } return fakeChild }) @@ -862,11 +988,19 @@ describe("safeWriteText", () => { }) // icacls save succeeds, restore fails - the same shape as the other two cases. - 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"), "", "") + // Only the restore fails. The restrictive fallback - drop the inherited ACEs, grant + // the current user sole full control, read the DACL back - succeeds, which is the + // alternative to failing the publish that the review row asks for. + vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + // The verification read-back: an explicit grant and no (I) inherited marker. + callback(null, `${argv[0]} ${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") } return fakeChild }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 1d6f4e9010..93012b204c 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -1,6 +1,7 @@ import * as fs from "fs/promises" import * as fsSync from "fs" import * as path from "path" +import * as os from "os" import { execFile } from "child_process" export interface SafeWriteTextOptions { @@ -90,6 +91,45 @@ export class PostCommitDurabilityError extends Error { this.targetPath = targetPath } } +/** + * Thrown when the DACL of an EXISTING Windows target could not be captured, so the commit + * rename would replace a file whose access rights are unknown with one that inherits whatever + * the destination directory grants. The write is refused before anything is committed: the + * alternative - publishing and warning - silently changes who can read a file the user could + * read before, which is a security boundary rather than a cosmetic difference. + */ +export class DaclCaptureError extends Error { + readonly targetPath: string + + constructor(targetPath: string, cause?: unknown) { + super( + "Refusing to publish over the existing target: its DACL could not be captured, so the replacement would change the file's access rights without knowing what they were. The target is unchanged; the path is reported here.", + { cause }, + ) + this.name = "DaclCaptureError" + this.targetPath = targetPath + } +} + +/** + * Thrown when the saved DACL could not be restored onto the committed file AND the restrictive + * fallback (drop inherited ACEs, grant the current user sole full control) could not be applied + * and verified. The committed bytes are rolled back onto the retained backup first, so a + * rejection never leaves content at the target that nobody authorized an ACL for. + */ +export class DaclRestoreError extends Error { + readonly targetPath: string + + constructor(targetPath: string, cause?: unknown) { + super( + "The saved DACL could not be restored onto the committed file and the restrictive fallback could not be verified; the previous content was rolled back onto the backup rather than published under an unknown ACL.", + { cause }, + ) + this.name = "DaclRestoreError" + this.targetPath = targetPath + } +} + // -- helpers --------------------------------------------------------------- /** Generate a unique temp file name in the given directory. */ @@ -168,6 +208,37 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu } } +/** + * Narrow a file's DACL to a single explicit grant for the current user, then verify it by + * reading the DACL back. Used when the saved DACL could not be reapplied: the row's alternative + * to failing is to publish under an ACL that is known to be restrictive, and "known" means + * read back - icacls reports an inherited ACE with an (I) flag, so a verified narrowing shows + * the grant and no inherited entry. + */ +async function _restrictDaclWindows( + filePath: string, + execFileRunner?: typeof execFile, + identity: string = os.userInfo().username, +): Promise { + const runner = execFileRunner ?? execFile + const applied = await new Promise((resolve) => { + runner("icacls", [filePath, "/inheritance:r", "/grant:r", `${identity}:F`], { windowsHide: true }, (err) => + resolve(!err), + ) + }) + if (!applied) { + return false + } + const readBack = await new Promise((resolve) => { + runner("icacls", [filePath], { windowsHide: true }, (err, stdout) => (err ? resolve(null) : resolve(stdout ?? ""))) + }) + if (readBack === null) { + return false + } + // Verification: the grant is present and nothing is inherited any more. + return readBack.toLowerCase().includes(identity.toLowerCase()) && !readBack.includes("(I)") +} + // -- public API ------------------------------------------------------------ /** @@ -470,12 +541,17 @@ 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.`) + // The target exists and its DACL could not be captured. Publishing anyway would + // replace a file whose access rights are unknown with one that inherits whatever + // the destination directory grants - a change in who can read the file, decided + // silently by a helper that failed. The refusal happens BEFORE the commit rename, + // so the target still holds its previous content under its previous ACL; the + // staging file this write created is removed with the refusal. + await fs.unlink(tempPath).catch(() => {}) + if (stagingDir) { + await fs.rmdir(stagingDir).catch(() => {}) + } + throw new DaclCaptureError(targetPath) } } else if (errorCode(accessError) !== "ENOENT") { // Not "absent": the target is there but could not be checked (EACCES, ...), so @@ -573,13 +649,27 @@ export async function safeWriteText( const restoredDir = path.dirname(targetPath) 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. - 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.`) + // The saved ACEs could not be reapplied. On a plain temp directory that is the + // normal outcome rather than the exception - measured on this host, icacls + // /restore exits 1300 with "Not all privileges or groups referenced are assigned + // to the caller" for every pairing - so failing here would break every publish. + // The row's alternative is to publish under an ACL that is KNOWN to be + // restrictive: drop the inherited entries, grant the current user sole full + // control, and verify it by reading the DACL back. + const narrowed = await _restrictDaclWindows(targetPath, options?.execFileRunner) + if (!narrowed) { + // Neither the original ACL nor a verified restrictive one could be put on the + // committed file, so the bytes do not stay at the target under an unknown + // ACL: the retained backup - the pre-write content - is renamed back and the + // write is reported as failed. + if (backupPath !== null && releaseBackupOnSuccess) { + await fs.rename(backupPath, targetPath) + backupPath = null + releaseBackupOnSuccess = false + } + throw new DaclRestoreError(targetPath) + } + warn(`safeWriteText: content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file's access rights were narrowed to the current user's full control and verified.`) } } From d4a537fa171025a91fc7144818b4fa4e99fa9f1c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 06:11:19 +0800 Subject: [PATCH 59/73] fix(test): teach main's unicode-reader spec about hasClippedLines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of 9d89bc1ea (landed on the U6 branch). This branch carries the same production field (5 uses of hasClippedLines in src/integrations/misc/indentation-reader.ts), and main added src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts, which pins the whole reader result with toEqual - a second-class merge conflict git cannot see, reproduced on this unit's merged tree: vitest --config vitest.misc.config.ts reported 5 failed, three of them here with "expected { …(6) } to deeply equal { …(5) }" and the extra key hasClippedLines: true. The spec's intent is untouched; the expectations learn the field, matching the production code: true for the two clipped-line cases, false for empty input and for both reader-error results. Contract change to an existing test, stated explicitly: it pinned the pre-merge shape. numstat 6/0. The negative control was measured on the U6 branch (forcing the production flag to false turns exactly the two clipped-line tests red; mutant restored byte-exactly), and the same expectations are verified green here: 28 passed. --- .../misc/__tests__/indentation-reader-unicode.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts index 90d5007f16..2496ee5088 100644 --- a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts @@ -74,6 +74,8 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 3, returnedLines: 1, wasTruncated: true, + // This unit's read-scope work reports whether any returned line was clipped. + hasClippedLines: true, }) expect(Buffer.from(result.content).toString("utf8")).toBe(result.content) }) @@ -115,6 +117,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 1, wasTruncated: false, + hasClippedLines: true, }) expect(readWithSlice("")).toEqual({ content: "1 | ", @@ -122,6 +125,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 1, wasTruncated: false, + hasClippedLines: false, }) }) @@ -132,6 +136,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, }) expect(readWithIndentation(longLine, { anchorLine: 0 })).toEqual({ content: "Error: anchor_line 0 is out of range (1-1)", @@ -139,6 +144,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, }) }) }) From 5b3768e15462fb5b4657588e7285d7d89fe73fee Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 06:35:36 +0800 Subject: [PATCH 60/73] fix(safe-write): every descriptor this call owns is released through the retry-and-report path The row: "Four descriptors are closed without the retry and logging used for the backup seed (_closeDescriptor): the staging descriptor, the caller-supplied temp descriptor, the backup fsync descriptor, and the parent directory descriptor. Route them through the same retry/logging close used for the backup seed (_closeDescriptor), with a test per descriptor." All four now follow the seed's ownership model: the close happens inside the try, the descriptor is cleared only once the close succeeded, and a close that is still owed when control leaves the block goes through _closeDescriptor - retried once, and the second failure reported rather than allowed to replace the error that explains what happened. The staging and caller-temp branches previously closed in a finally, which meant a close failure masked the write's own failure; the backup-fsync and parent-directory descriptors previously abandoned the descriptor on an fsync failure. Red first: the four new tests in "descriptor release retries and reports" fail before the change (the close is attempted once and nothing is reported). Green: 72 passed in the spec, 76 with the integration spec. Negative controls, one per descriptor - replacing that site's _closeDescriptor call with a bare closeSync turns exactly that descriptor's test red and nothing else (1 failed | 71 passed, four times), and doing all four at once turns exactly those four red (4 failed | 68 passed); the mutant was restored byte-exactly (62e19226c507). The tests distinguish the descriptors by the flags each open uses, since the backup seed and the backup fsync handle open the same path. No existing test changed: the success paths still close exactly once, which the pre-existing "calls fsync on the fd before close, and rename after close" and cleanup tests pin. tsc --noEmit reports 0 errors in the files this change touches; eslint --max-warnings=0 --prune-suppressions is clean and src/eslint-suppressions.json is untouched. --- .../__tests__/safeWriteText.spec.ts | 90 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 77 +++++++++++----- 2 files changed, 144 insertions(+), 23 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index c6c91e8110..0152401601 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1628,3 +1628,93 @@ describe("resolveLockKey when the parent directory does not exist yet", () => { await expect(resolveLockKey(target)).rejects.toThrow("EACCES") }) }) + +// The backup seed descriptor already goes through _closeDescriptor: a close that fails is +// retried once, the second failure is logged rather than allowed to replace the operation's +// own error, and the descriptor is not abandoned. The four descriptors below are the ones the +// review row names as not doing that yet - one test per descriptor. +describe("descriptor release retries and reports", () => { + const targetPath = "/tmp/test-dir/target.txt" + const dirPath = path.resolve("/tmp/test-dir") + const closeErr = () => Object.assign(new Error("EBADF close"), { code: "EBADF" }) + + beforeEach(() => { + mockDefaults() + // The staging write reports every requested byte as written. + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => (typeof args[3] === "number" ? args[3] : 0)) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + }) + + it("a close failure on the staging descriptor is retried and logged", async () => { + vi.mocked(fsSync.openSync).mockReturnValue(3) + vi.mocked(fsSync.closeSync).mockImplementation(() => { + throw closeErr() + }) + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toThrow("EBADF close") + + // Retried once, and the second failure reported instead of swallowed. + expect(fsSync.closeSync).toHaveBeenCalledTimes(2) + // _closeDescriptor reports the label together with the error it swallowed. + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("staging descriptor for"), expect.anything()) + errSpy.mockRestore() + }) + + it("a close failure on a caller-supplied temp descriptor is retried and logged", async () => { + const callerTemp = path.join(dirPath, "caller-staged.tmp") + vi.mocked(fsSync.openSync).mockReturnValue(4) + vi.mocked(fsSync.closeSync).mockImplementation(() => { + throw closeErr() + }) + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteText(targetPath, "data", { platform: "linux", tempPath: callerTemp })).rejects.toThrow("EBADF close") + + expect(fsSync.closeSync).toHaveBeenCalledTimes(2) + // _closeDescriptor reports the label together with the error it swallowed. + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("staged temp descriptor for"), expect.anything()) + errSpy.mockRestore() + }) + + it("a close failure on the backup fsync descriptor is retried and logged", async () => { + // The seed and the fsync handle open the same backup path, so the flags tell them + // apart: only the fsync handle (r+) gets the descriptor whose close fails. + vi.mocked(fsSync.openSync).mockImplementation((target, flags) => + String(target).includes("safeWriteText.bak") && String(flags) === "r+" ? 5 : 3, + ) + vi.mocked(fsSync.closeSync).mockImplementation((fd) => { + if (fd === 5) { + throw closeErr() + } + }) + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteText(targetPath, "data", { platform: "linux", backup: true })).rejects.toThrow("EBADF close") + + expect(vi.mocked(fsSync.closeSync).mock.calls.filter((c) => c[0] === 5)).toHaveLength(2) + // _closeDescriptor reports the label together with the error it swallowed. + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("backup fsync descriptor for"), expect.anything()) + errSpy.mockRestore() + }) + + it("a close failure on the parent directory descriptor is retried, logged, and still reported as a durability failure", async () => { + // The parent directory is the only descriptor opened read-only. + vi.mocked(fsSync.openSync).mockImplementation((_target, flags) => (String(flags) === "r" ? 7 : 3)) + vi.mocked(fsSync.closeSync).mockImplementation((fd) => { + if (fd === 7) { + throw closeErr() + } + }) + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // The rename committed, so this is the post-commit durability path: the close failure is + // retried and logged, and the caller still gets the durability error it needs. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toThrow(PostCommitDurabilityError) + + expect(vi.mocked(fsSync.closeSync).mock.calls.filter((c) => c[0] === 7)).toHaveLength(2) + // _closeDescriptor reports the label together with the error it swallowed. + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("parent directory descriptor for"), expect.anything()) + errSpy.mockRestore() + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 93012b204c..6fb181551b 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -454,21 +454,33 @@ export async function safeWriteText( // 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) + // Ownership of the descriptor follows the backup seed's model: it is released + // inside the try, and only a close that is still owed is retried through + // _closeDescriptor in the catch. A close that fails must not become the error this + // write reports when the write itself already failed - the operation's own failure + // is the one that explains what happened - and it must not be abandoned either. + let stagingFd: number | undefined try { + const handle = fsSync.openSync(tempPath, "w", targetMode) + stagingFd = handle if (targetExists) { - fsSync.fchmodSync(fd, targetMode) + fsSync.fchmodSync(handle, 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) + offset += fsSync.writeSync(handle, buffer, offset, buffer.length - offset) } - _fsyncFile(fd) - } finally { - fsSync.closeSync(fd) + _fsyncFile(handle) + fsSync.closeSync(handle) + stagingFd = undefined + } catch (error: unknown) { + if (stagingFd !== undefined) { + _closeDescriptor(stagingFd, `staging descriptor for ${tempPath}`) + } + throw error } } else { // Preserve the existing target's mode (CWE-732): the caller-staged @@ -484,14 +496,23 @@ export async function safeWriteText( if (errorCode(error) !== "ENOENT") throw error // target does not exist yet - keep the temp's default mode } - const fd = fsSync.openSync(tempPath, "r+") + // Same ownership model as the self-staged branch above: the close is retried and + // reported through _closeDescriptor rather than replacing the write's own failure. + let stagedFd: number | undefined try { + const handle = fsSync.openSync(tempPath, "r+") + stagedFd = handle if (targetMode !== null) { - fsSync.fchmodSync(fd, targetMode) + fsSync.fchmodSync(handle, targetMode) } - _fsyncFile(fd) - } finally { - fsSync.closeSync(fd) + _fsyncFile(handle) + fsSync.closeSync(handle) + stagedFd = undefined + } catch (error: unknown) { + if (stagedFd !== undefined) { + _closeDescriptor(stagedFd, `staged temp descriptor for ${tempPath}`) + } + throw error } } @@ -572,6 +593,7 @@ export async function safeWriteText( // 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. let seedFd: number | undefined + let backupFd: number | undefined 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 @@ -591,12 +613,12 @@ export async function safeWriteText( 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) - } + backupFd = fsSync.openSync(backupPath, "r+") + _fsyncFile(backupFd) + fsSync.closeSync(backupFd) + // Owned no longer: only after the close succeeds is the descriptor somebody + // else's problem; until then the handler below retries it. + backupFd = undefined } catch (backupError: unknown) { if (seedFd !== undefined) { // The seed descriptor is still owned here: its close failed, or an error landed @@ -605,6 +627,10 @@ export async function safeWriteText( // handler is about to rethrow. _closeDescriptor(seedFd, `backup seed descriptor for ${backupPath}`) } + if (backupFd !== undefined) { + // Same rule for the fsync descriptor of the backup copy. + _closeDescriptor(backupFd, `backup fsync descriptor for ${backupPath}`) + } // 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(() => {}) @@ -623,14 +649,19 @@ export async function safeWriteText( // -- 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") { + let dirFd: number | undefined try { - const dirFd = fsSync.openSync(dirPath, "r") - try { - _fsyncFile(dirFd) - } finally { - fsSync.closeSync(dirFd) - } + const handle = fsSync.openSync(dirPath, "r") + dirFd = handle + _fsyncFile(handle) + fsSync.closeSync(handle) + dirFd = undefined } catch (error: unknown) { + if (dirFd !== undefined) { + // The close is retried and logged rather than replacing the durability + // failure this block is about to report. + _closeDescriptor(dirFd, `parent directory descriptor for ${dirPath}`) + } // 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, From 6329f3bdf1dedddd03ee4132886a1ecaba7a9f6d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 07:59:52 +0800 Subject: [PATCH 61/73] fix(safe-write): a failed Windows ACL step never publishes content, and a close is never attempted twice Three review findings on the win32 publish path, all of the family "the failure is reported after the damage". **DaclRestoreError could leave the new content at the target, and its rollback could remove the old content (Major, Security & Privacy).** The class doc promises the committed bytes are rolled back onto the retained backup, and the code broke that three ways. (1) With the default `backup: false`, a DACL dump was still captured for an existing target, so step 5 ran, but there was nothing to roll back onto: a restore that failed and a narrowing that could not be verified threw while the new bytes stayed at the target under the directory's inherited ACL - a failed save whose content had already been published. A publish that captures a DACL now also retains the pre-write content privately, whatever the caller asked for; the copy is unlinked on success, so a caller that did not ask for a backup never sees one. (2) The rolled-back file is the backup, whose access rights came from the directory rather than from the original target, so the same verified narrowing runs on it; if that fails too the caller is told the content is back but carries its directory's access rights. (3) The rollback rename cleared ownership only after it succeeded, so a rename that itself failed left the backup owned by the outer handler, which unlinked it - the only copy of the pre-write content, destroyed by a cleanup on the way out of a failed write. Ownership now transfers before the rename, and a failed rollback reports where the retained copy is. **A descriptor whose close has been attempted is never closed again (Minor, Stability & Availability).** Every site kept ownership when `closeSync` threw, and the handler closed the same number a second time. The first `close(2)` may already have released the descriptor even while reporting an error, so the retry can release a descriptor a concurrent open has meanwhile been handed. Ownership now ends at the attempt, and the failure is logged where it happens; `_closeDescriptor` remains only for errors that arrive before any close was attempted. Contract change to existing tests, stated explicitly and none deleted: five tests asserted the retry (a close attempted twice, and a failed parent-directory close reported as PostCommitDurabilityError). They now assert the invariant instead - no descriptor number is closed twice in a write - and that a close failing after a successful fsync is reported without turning a durable write into a durability failure, which is what the evidence says. **The narrowing check passed on the file path alone (Minor, Security & Privacy).** `icacls ` prints the path first, and a workspace path normally contains the user's name (`C:\Users\\...), so a substring match accepted a file that grants that user nothing; an explicit ACE for another principal also passed. The report's access-control entries are now parsed: every entry must be an explicit grant to the current user with no inherited component, and a principal such as `NT AUTHORITY\SYSTEM` survives intact rather than being truncated at its first space. Also renames one test whose title said the opposite of its assertions (it asserted a `DaclCaptureError` rejection with no warning, under a title claiming a warning was reported). Measured: 75 passed in the spec (3 new tests), 106 passed | 4 skipped across the file-safety and safeWriteJson specs. Negative controls, each restored byte-exactly (7780232e3cee): drop the private backup -> exactly the two new rollback tests red; restore the pre-fix rollback ordering -> exactly the retained-backup test red; go back to the substring check -> exactly the path-only read-back test red; retry the staging close -> exactly the staging close test red. `tsc --noEmit` with a temporary `paths` override pointing `@roo-code/types` at this tree's `packages/types/src/index.ts`: **0 errors**. `eslint . --ext=ts --max-warnings=0`: exit 0. --- .../__tests__/safeWriteText.spec.ts | 188 ++++++++++++++---- src/services/file-safety/safeWriteText.ts | 137 +++++++++++-- 2 files changed, 269 insertions(+), 56 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 0152401601..3b6c82f415 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -561,7 +561,7 @@ describe("safeWriteText", () => { expect(vi.mocked(fsSync.closeSync).mock.calls.filter((call) => Number(call[0]) === 7)).toHaveLength(1) }) - it("a failing seed-descriptor close is retried and never leaves the descriptor unreleased", async () => { + it("a failing seed-descriptor close is reported once and does not fail the backup", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) // The backup destination is seeded with openSync("wx") and released immediately. Give @@ -569,9 +569,12 @@ describe("safeWriteText", () => { // seed's close fail: at that moment the descriptor is still owned by this call, while // the error this write ends up reporting is the backup failure. let backupOpens = 0 + let otherFd = 100 const openedFds: number[] = [] vi.mocked(fsSync.openSync).mockImplementation((target: unknown) => { - const fd = String(target).includes("safeWriteText.bak_") ? (backupOpens++ === 0 ? 7 : 8) : 1 + // Distinct numbers per open, so "no descriptor is closed twice" can be checked + // against the numbers themselves. + const fd = String(target).includes("safeWriteText.bak_") ? (backupOpens++ === 0 ? 7 : 8) : otherFd++ openedFds.push(fd) return fd }) @@ -583,21 +586,28 @@ describe("safeWriteText", () => { } }) - await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EBADF") + // A close that has been attempted is never attempted again: the first close(2) may have + // released the descriptor even while reporting EBADF, and a second close could then + // release a descriptor a concurrent open has meanwhile been handed. The failure is + // reported where it happened, and the backup - which is what this descriptor was for - + // is complete, so the write itself is not failed by it. + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).resolves.toBeUndefined() - // The close is attempted again by the backup failure handler rather than the descriptor - // being abandoned, and that retry's failure is reported without replacing the error the - // write fails with. const seedAttempts = vi.mocked(fsSync.closeSync).mock.calls.filter((call) => Number(call[0]) === seedFd).length - expect(seedAttempts).toBe(2) + expect(seedAttempts).toBe(1) + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("backup seed descriptor for"), expect.anything()) expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("backup seed descriptor"), expect.anything()) // Every descriptor this write opened was handed to closeSync: no handle outlives the // failed backup. for (const fd of openedFds) { expect(vi.mocked(fsSync.closeSync).mock.calls.some((call) => Number(call[0]) === fd)).toBe(true) } - // The partial backup and the staging temp are still cleaned up. - expect(fs.rename).not.toHaveBeenCalled() + // The backup itself is complete - the seed descriptor was only ever a mode-fixed + // placeholder - so the write goes on to commit and then discards the backup it no longer + // needs. + const closedFds = vi.mocked(fsSync.closeSync).mock.calls.map((call) => Number(call[0])) + expect(new Set(closedFds).size).toBe(closedFds.length) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) consoleError.mockRestore() }) @@ -672,7 +682,7 @@ describe("safeWriteText", () => { expect(execFile).toHaveBeenCalledTimes(1) }) - it("win32: reports that access rights may change when the DACL cannot be saved", async () => { + it("win32: refuses the write without an onWarning notice 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) @@ -1633,7 +1643,7 @@ describe("resolveLockKey when the parent directory does not exist yet", () => { // retried once, the second failure is logged rather than allowed to replace the operation's // own error, and the descriptor is not abandoned. The four descriptors below are the ones the // review row names as not doing that yet - one test per descriptor. -describe("descriptor release retries and reports", () => { +describe("descriptor release is attempted once and reported", () => { const targetPath = "/tmp/test-dir/target.txt" const dirPath = path.resolve("/tmp/test-dir") const closeErr = () => Object.assign(new Error("EBADF close"), { code: "EBADF" }) @@ -1645,39 +1655,44 @@ describe("descriptor release retries and reports", () => { vi.mocked(fs.realpath).mockResolvedValue(targetPath) }) - it("a close failure on the staging descriptor is retried and logged", async () => { - vi.mocked(fsSync.openSync).mockReturnValue(3) + it("a close failure on the staging descriptor is attempted once and logged", async () => { + let nextFd = 20 + vi.mocked(fsSync.openSync).mockImplementation(() => nextFd++) vi.mocked(fsSync.closeSync).mockImplementation(() => { throw closeErr() }) const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toThrow("EBADF close") + // The content was staged and fsynced before the close, so a close that fails is a leak to + // report, not a reason to fail the write. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).resolves.toBeUndefined() - // Retried once, and the second failure reported instead of swallowed. - expect(fsSync.closeSync).toHaveBeenCalledTimes(2) - // _closeDescriptor reports the label together with the error it swallowed. - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("staging descriptor for"), expect.anything()) + // The invariant is that no descriptor number is closed twice, whatever else this write + // opened with the same number. + const closedFds = vi.mocked(fsSync.closeSync).mock.calls.map((c) => Number(c[0])) + expect(new Set(closedFds).size).toBe(closedFds.length) + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("staging descriptor for"), expect.anything()) errSpy.mockRestore() }) - it("a close failure on a caller-supplied temp descriptor is retried and logged", async () => { + it("a close failure on a caller-supplied temp descriptor is attempted once and logged", async () => { const callerTemp = path.join(dirPath, "caller-staged.tmp") - vi.mocked(fsSync.openSync).mockReturnValue(4) + let nextFd = 30 + vi.mocked(fsSync.openSync).mockImplementation(() => nextFd++) vi.mocked(fsSync.closeSync).mockImplementation(() => { throw closeErr() }) const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - await expect(safeWriteText(targetPath, "data", { platform: "linux", tempPath: callerTemp })).rejects.toThrow("EBADF close") + await expect(safeWriteText(targetPath, "data", { platform: "linux", tempPath: callerTemp })).resolves.toBeUndefined() - expect(fsSync.closeSync).toHaveBeenCalledTimes(2) - // _closeDescriptor reports the label together with the error it swallowed. - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("staged temp descriptor for"), expect.anything()) + const closedFds = vi.mocked(fsSync.closeSync).mock.calls.map((c) => Number(c[0])) + expect(new Set(closedFds).size).toBe(closedFds.length) + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("staged temp descriptor for"), expect.anything()) errSpy.mockRestore() }) - it("a close failure on the backup fsync descriptor is retried and logged", async () => { + it("a close failure on the backup fsync descriptor is attempted once and logged", async () => { // The seed and the fsync handle open the same backup path, so the flags tell them // apart: only the fsync handle (r+) gets the descriptor whose close fails. vi.mocked(fsSync.openSync).mockImplementation((target, flags) => @@ -1690,15 +1705,14 @@ describe("descriptor release retries and reports", () => { }) const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - await expect(safeWriteText(targetPath, "data", { platform: "linux", backup: true })).rejects.toThrow("EBADF close") + await expect(safeWriteText(targetPath, "data", { platform: "linux", backup: true })).resolves.toBeUndefined() - expect(vi.mocked(fsSync.closeSync).mock.calls.filter((c) => c[0] === 5)).toHaveLength(2) - // _closeDescriptor reports the label together with the error it swallowed. - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("backup fsync descriptor for"), expect.anything()) + expect(vi.mocked(fsSync.closeSync).mock.calls.filter((c) => c[0] === 5)).toHaveLength(1) + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("backup fsync descriptor for"), expect.anything()) errSpy.mockRestore() }) - it("a close failure on the parent directory descriptor is retried, logged, and still reported as a durability failure", async () => { + it("a close failure on the parent directory descriptor is logged without failing the durable write", async () => { // The parent directory is the only descriptor opened read-only. vi.mocked(fsSync.openSync).mockImplementation((_target, flags) => (String(flags) === "r" ? 7 : 3)) vi.mocked(fsSync.closeSync).mockImplementation((fd) => { @@ -1708,13 +1722,115 @@ describe("descriptor release retries and reports", () => { }) const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - // The rename committed, so this is the post-commit durability path: the close failure is - // retried and logged, and the caller still gets the durability error it needs. - await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toThrow(PostCommitDurabilityError) + // The durability question this block answers belongs to the fsync, which succeeded. A close + // that fails afterwards is a leak to report: turning it into PostCommitDurabilityError would + // tell the caller its directory entry may not be durable when the evidence says it is. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).resolves.toBeUndefined() - expect(vi.mocked(fsSync.closeSync).mock.calls.filter((c) => c[0] === 7)).toHaveLength(2) - // _closeDescriptor reports the label together with the error it swallowed. - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("parent directory descriptor for"), expect.anything()) + expect(vi.mocked(fsSync.closeSync).mock.calls.filter((c) => c[0] === 7)).toHaveLength(1) + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("parent directory descriptor for"), expect.anything()) errSpy.mockRestore() }) }) + +describe("windows ACL failure leaves no unauthorized content", () => { + const targetPath = "/tmp/test-dir/target.txt" + + beforeEach(() => { + mockDefaults() + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => (typeof args[3] === "number" ? args[3] : 0)) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(3) + }) + + // icacls is dispatched by its arguments so only the operation under test can be made to fail. + const icacls = (handler: (argv: string[]) => { error?: unknown; stdout?: string }) => + vi.mocked(execFile).mockImplementation(((_cmd: string, argv: string[], _opts: unknown, cb: (err: unknown, stdout?: string) => void) => { + const outcome = handler(argv) + return void cb(outcome.error ?? null, outcome.stdout ?? "") + }) as never) + + it("rolls the previous content back even when the caller asked for no backup", async () => { + // Without a copy to roll back onto, a DACL that can be neither restored nor narrowed would + // leave the new bytes at the target under an inherited ACL - content published under access + // rights nobody authorized, reported as a failed save. So a publish that captures a DACL also + // retains the pre-write content privately, whatever the caller asked for. + const grants: string[][] = [] + icacls((argv) => { + grants.push(argv) + if (argv.includes("/save")) { + return { stdout: "" } + } + if (argv.includes("/restore")) { + return { error: Object.assign(new Error("icacls 1300"), { code: 1300 }) } + } + if (argv.includes("/grant:r")) { + return { error: Object.assign(new Error("icacls 5"), { code: 5 }) } + } + return { stdout: "no access-control entries in this report" } + }) + + await expect(safeWriteText(targetPath, "new data", { platform: "win32" })).rejects.toThrow(DaclRestoreError) + + // The pre-write content is renamed back over the target, and the copy that made it possible + // exists even though this caller passed no backup option. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect( + vi.mocked(fs.rename).mock.calls.some((call) => String(call[0]).includes("safeWriteText.bak_") && call[1] === targetPath), + ).toBe(true) + // The rolled-back file is the backup, whose access rights came from its directory rather than + // from the original target, so the verified narrowing runs on it too. + expect(grants.filter((argv) => argv[0] === targetPath && argv.includes("/grant:r")).length).toBeGreaterThanOrEqual(2) + }) + + it("keeps the retained backup when the rollback rename itself fails", async () => { + // Once the commit has replaced the target, the backup is the only copy of the pre-write + // content. A cleanup that unlinks it because the write is failing turns a failed save into + // lost data - the same shape as a finally block swallowing the error it follows. + icacls((argv) => { + if (argv.includes("/save")) { + return { stdout: "" } + } + if (argv.includes("/restore")) { + return { error: Object.assign(new Error("icacls 1300"), { code: 1300 }) } + } + if (argv.includes("/grant:r")) { + return { error: Object.assign(new Error("icacls 5"), { code: 5 }) } + } + return { stdout: "no access-control entries in this report" } + }) + vi.mocked(fs.rename).mockImplementation((async (from: unknown) => { + if (String(from).includes("safeWriteText.bak_")) { + throw Object.assign(new Error("EPERM rollback"), { code: "EPERM" }) + } + }) as never) + const warn = vi.fn() + + await expect(safeWriteText(targetPath, "new data", { platform: "win32", onWarning: warn })).rejects.toThrow( + DaclRestoreError, + ) + + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + expect(warn).toHaveBeenCalledWith(expect.stringContaining("retained at")) + }) + + it("refuses a read-back whose only mention of the user is the file path", async () => { + // icacls prints the path first, and a workspace path normally contains the user name + // (C:\Users\\...), so a substring match would accept a file that grants that user + // nothing at all. + icacls((argv) => { + if (argv.includes("/save")) { + return { stdout: "" } + } + if (argv.includes("/restore")) { + return { error: Object.assign(new Error("icacls 1300"), { code: 1300 }) } + } + if (argv.includes("/grant:r")) { + return { stdout: "" } + } + return { stdout: "C:\\Users\\eason\\Documents\\target.txt NT AUTHORITY\\SYSTEM:(F)" } + }) + + await expect(safeWriteText(targetPath, "new data", { platform: "win32" })).rejects.toThrow(DaclRestoreError) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 6fb181551b..bb038e10c2 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -158,10 +158,12 @@ function _stagingDir(dir: string): string { } /** - * Release a descriptor this call still owns. A close that fails must not become the error a - * failed write reports - the operation's own failure is the one that explains what happened - - * but the descriptor must not be abandoned either, so the caller retries through here and the - * second failure is reported. + * Release a descriptor this call still owns, meaning one whose close has not been attempted yet. + * A close that has already been attempted is never retried: the first close(2) may have released + * the descriptor even while reporting an error, and closing the same number again can then release + * a descriptor a concurrent open has meanwhile been handed. Such a failure is logged where it + * happens instead. This helper only reports its own failure, so it can never replace the error a + * failed write is about to throw. */ function _closeDescriptor(fd: number, label: string): void { try { @@ -208,6 +210,45 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu } } +/** + * Decide whether an `icacls ` report describes a DACL narrowed to one principal. + * + * The report is a header line naming the file followed by one line per access-control entry, e.g. + * "NT AUTHORITY\\SY:\\Users:(RX)" or "DESKTOP\\bob:(I)(F)". Entries are taken from the lines after + * the header, so the path - which usually contains the user's own name - can never satisfy the + * check on its own. An entry is accepted only when it carries no "(I)" inherited component and its + * principal is exactly the identity the narrowing granted. + */ +function _aclEntriesAreNarrowedTo(report: string, identity: string): boolean { + // Every "principal:(flags)" pair in the report is an entry; the file path that icacls prints + // ahead of the first entry is stripped below so it cannot satisfy the check by containing the + // user's name. + const pattern = /([^:()]+):\(([^()]*)\)/g + const wanted = identity.toLowerCase() + let count = 0 + for (const match of report.matchAll(pattern)) { + let principal = match[1].trim() + // The first entry shares its line with the path. Only a leading token that looks like a + // path is dropped: a principal such as "NT AUTHORITY\\SYSTEM" legitimately contains a space + // and must survive intact. + const parts = principal.split(/\s+/) + if (parts.length > 1 && /^[A-Za-z]:[\\/]|^[\\/]/.test(parts[0])) { + principal = parts.slice(1).join(" ").trim() + } + const flags = match[2] + if (flags.includes("(I)")) { + return false + } + const lower = principal.toLowerCase() + const isWanted = lower === wanted || lower === `${wanted}` || lower.endsWith(`\\${wanted}`) || lower.endsWith(`/${wanted}`) + if (!isWanted) { + return false + } + count++ + } + return count > 0 +} + /** * Narrow a file's DACL to a single explicit grant for the current user, then verify it by * reading the DACL back. Used when the saved DACL could not be reapplied: the row's alternative @@ -235,8 +276,12 @@ async function _restrictDaclWindows( if (readBack === null) { return false } - // Verification: the grant is present and nothing is inherited any more. - return readBack.toLowerCase().includes(identity.toLowerCase()) && !readBack.includes("(I)") + // Verification reads the access-control entries, not the whole output: icacls prints the file + // path first, and a workspace path normally contains the user's name (C:\Users\\...), + // so a substring match would pass on a file that grants that user nothing. Every entry must be + // an explicit grant to the current user with no inherited component, which is the only state + // this helper claims to have verified. + return _aclEntriesAreNarrowedTo(readBack, identity) } // -- public API ------------------------------------------------------------ @@ -474,8 +519,13 @@ export async function safeWriteText( offset += fsSync.writeSync(handle, buffer, offset, buffer.length - offset) } _fsyncFile(handle) - fsSync.closeSync(handle) + // Ownership ends when the close is attempted, not when it succeeds. stagingFd = undefined + try { + fsSync.closeSync(handle) + } catch (closeError: unknown) { + console.error(`Failed to close the staging descriptor for ${tempPath}:`, closeError) + } } catch (error: unknown) { if (stagingFd !== undefined) { _closeDescriptor(stagingFd, `staging descriptor for ${tempPath}`) @@ -506,8 +556,13 @@ export async function safeWriteText( fsSync.fchmodSync(handle, targetMode) } _fsyncFile(handle) - fsSync.closeSync(handle) + // Ownership ends when the close is attempted, not when it succeeds. stagedFd = undefined + try { + fsSync.closeSync(handle) + } catch (closeError: unknown) { + console.error(`Failed to close the staged temp descriptor for ${tempPath}:`, closeError) + } } catch (error: unknown) { if (stagedFd !== undefined) { _closeDescriptor(stagedFd, `staged temp descriptor for ${tempPath}`) @@ -582,8 +637,14 @@ export async function safeWriteText( } } try { - // -- Step 3 (backup:true): durable copy target -> backup ---- - if (options?.backup) { + // -- Step 3 (backup:true, or any win32 publish that captured a DACL): durable copy ---- + // A caller that asked for no backup still gets a private copy whenever step 2 saved a + // DACL dump, because step 5's only safe failure mode is a rollback onto the pre-write + // content: without it, a DACL that could be neither restored nor narrowed would leave + // new bytes at the target under an ACL nobody authorized - the exact condition the + // security row refuses. The copy is unlinked on success, so a caller that did not ask + // for a backup never sees one. + if (options?.backup || daclDumpPath !== null) { try { await fs.access(targetPath) backupPath = _tempName(dirPath, "safeWriteText.bak") @@ -605,20 +666,29 @@ export async function safeWriteText( // the chmod afterwards is what clears a copied read-only attribute on Windows // and keeps a backup of a permissive file private. seedFd = fsSync.openSync(backupPath, "wx", 0o600) - fsSync.closeSync(seedFd) - // Owned no longer: only after the close succeeds is the descriptor somebody - // else's problem. If the close throws, the handler below still holds it. + // Owned no longer the moment the close is attempted: a retried close could + // release a descriptor somebody else has been handed in the meantime. + const seedHandle = seedFd seedFd = undefined + try { + fsSync.closeSync(seedHandle) + } catch (closeError: unknown) { + console.error(`Failed to close the backup seed descriptor for ${backupPath}:`, closeError) + } 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. backupFd = fsSync.openSync(backupPath, "r+") _fsyncFile(backupFd) - fsSync.closeSync(backupFd) - // Owned no longer: only after the close succeeds is the descriptor somebody - // else's problem; until then the handler below retries it. + // Same rule for the backup's fsync descriptor. + const backupHandle = backupFd backupFd = undefined + try { + fsSync.closeSync(backupHandle) + } catch (closeError: unknown) { + console.error(`Failed to close the backup fsync descriptor for ${backupPath}:`, closeError) + } } catch (backupError: unknown) { if (seedFd !== undefined) { // The seed descriptor is still owned here: its close failed, or an error landed @@ -654,12 +724,21 @@ export async function safeWriteText( const handle = fsSync.openSync(dirPath, "r") dirFd = handle _fsyncFile(handle) - fsSync.closeSync(handle) + // The question this block answers is whether the directory entry reached the + // disk, which the fsync above has already answered. A close that fails after it + // is therefore reported rather than turned into a durability failure, and it is + // not retried: the descriptor is released as far as the kernel is concerned, and + // a second close could release a number a concurrent open has been handed. dirFd = undefined + try { + fsSync.closeSync(handle) + } catch (closeError: unknown) { + console.error(`Failed to close the parent directory descriptor for ${dirPath}:`, closeError) + } } catch (error: unknown) { if (dirFd !== undefined) { - // The close is retried and logged rather than replacing the durability - // failure this block is about to report. + // Only an error that arrived before any close was attempted can still find + // this descriptor owned - the fsync failing, for instance. _closeDescriptor(dirFd, `parent directory descriptor for ${dirPath}`) } // The content rename committed, but the directory entry that @@ -694,9 +773,27 @@ export async function safeWriteText( // ACL: the retained backup - the pre-write content - is renamed back and the // write is reported as failed. if (backupPath !== null && releaseBackupOnSuccess) { - await fs.rename(backupPath, targetPath) + // Ownership of the backup transfers before the rename, not after it. If the + // rename itself fails the backup is still the only copy of the pre-write + // content, and the outer handler must not unlink it just because this call + // is failing - that is how a cleanup swallows the user's data. + const rollbackPath = backupPath backupPath = null releaseBackupOnSuccess = false + try { + await fs.rename(rollbackPath, targetPath) + } catch (rollbackError: unknown) { + warn(`safeWriteText: the DACL of ${targetPath} could not be restored and its content could not be rolled back; the pre-write content is retained at ${rollbackPath}.`) + throw new DaclRestoreError(targetPath, rollbackError) + } + // The rolled-back file is the backup, whose access rights came from the + // directory rather than from the original target, so the same verification + // runs on it: a rollback that restores content under an unknown ACL has not + // finished its job either. + const rolledBackAcl = await _restrictDaclWindows(targetPath, options?.execFileRunner) + if (!rolledBackAcl) { + warn(`safeWriteText: the previous content is back at ${targetPath}, but its access rights could not be narrowed or verified; the file carries the access rights of its directory.`) + } } throw new DaclRestoreError(targetPath) } From 5ad9dde419da3b5a3e00eb32b6ad334466fbed68 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 08:04:41 +0800 Subject: [PATCH 62/73] fix(safe-write-json): an unscoped write locks and reads the file it actually replaces Two review findings, one family: the lock key and the merge read described a file this write does not touch. **The lock key was the referent while the publish replaced the link (Minor, Functional Correctness).** `resolveLockKey` follows a symlink, so an unscoped write locked the referent; but an unscoped write publishes over the link itself (only a caller that declared a confinement scope publishes through the referent, the boundary the confined callers exist to protect). After the first such publish the path is no longer a link, so a later caller computes a different key for the same file, and a writer that computed its key earlier is still waiting on the referent's lock: two writers, two locks, one publish target - the lost update the lock exists to prevent, under `merge` semantics where the later commit silently discards the other update. The key is now the link path for an unscoped write and the referent only for a confined one, which is the same rule the publish already follows. This is the general shape behind the compare-and-swap finding on #1408: a guard that names one inode while the operation replaces another is not a guard, whatever it resolves. **The merge read followed a link the publish would not (Minor, Security & Privacy).** With no scope declared, `resolvedTargetPath` is the path the caller named, and the merge branch read it with `readFile`, which follows an existing symlink - so an unscoped merge could read JSON from outside the requested path and copy it into the replacement file that replaces the link. The merge now sees no existing document when the path is a symlink, exactly as it would if the path did not exist; the ENOENT and parse-error handling is unchanged, and a real resolution error still propagates. Contract change to existing tests, stated explicitly, none deleted: the two lock-key tests that reached the referent-locking and in-lock-rejection paths did so through an unscoped call, which no longer resolves a publish target at all; they now declare a confinement scope, which is the caller that locks and publishes through the referent, and their assertions follow that (the bytes land on the referent, and the sequence assertion counts the scope's own resolutions rather than pinning an exact array). Measured: 115 passed | 4 skipped across the file-safety and safeWriteJson specs. Negative controls, each restored byte-exactly (c8e435d92963): resolving the key unconditionally turns exactly the new lock-key test red; reading unconditionally in the merge branch turns exactly the new merge test red. `eslint . --ext=ts --max-warnings=0` exit 0; `tsc --noEmit` with a temporary `paths` override pointing `@roo-code/types` at this tree's `packages/types/src/index.ts`: 0 errors. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 61 +++++++++++++++++-- src/utils/safeWriteJson.ts | 28 ++++++++- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index f3e380616b..83a4a73f71 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -91,7 +91,9 @@ describe("safeWriteJson lock key under a peer commit", () => { return async () => {} }) - await safeWriteJson(currentLink, { id: "task-1" }) + // A confined writer is the one that publishes through the referent, and therefore the one that + // locks it; an unscoped write replaces the link and locks the link path instead. + await safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir }) // 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. @@ -103,11 +105,17 @@ describe("safeWriteJson lock key under a peer commit", () => { // A write that declares no confinement scope no longer resolves the publish target at all, // so the two post-lock resolutions this used to record are gone; the lock key still comes // from the referent, which is what the test is about. - expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "lstat", "lstat"]) - // The bytes replaced the link rather than travelling through it to the referent: nobody - // authorized the referent here, because no scope was declared. - expect(JSON.parse(await fs.readFile(currentLink, "utf8"))).toEqual({ id: "task-1" }) - await expect(fs.readFile(referent, "utf8")).rejects.toThrow() + expect(order.slice(0, 2)).toEqual(["resolve-failed", "lstat"]) + const lockAt = order.indexOf("lock") + expect(order.slice(0, lockAt)).toContain("resolve") + // A confined writer resolves its scope root on both sides of the lock, so how many times + // the resolution ran is not what this test is about: the caller queued behind the peer on + // the referent's lock, and safeWriteText's two staging checks still come last. + expect(order.slice(lockAt).filter((entry) => entry === "lstat")).toHaveLength(2) + expect(order[order.length - 1]).toBe("lstat") + // A confined writer publishes through the referent, which is the file it locked: the bytes + // land on the referent and the link keeps pointing at them. + expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) }) it("releases the lock when the resolution under the lock rejects", async () => { @@ -198,3 +206,44 @@ it("does not log a cleanup error when the safety net finds the temp file already unlinkSpy.mockRestore() consoleError.mockRestore() }) + it("locks the link path when the caller declared no confinement scope", async () => { + // The lock key must name the file this write replaces. An unscoped write publishes over the + // link, so locking the referent would let it run beside another writer that locked the link - + // two locks for one publish target, and the lost update the lock exists to prevent. + const dir = await makeDir("lockkey-unscoped-") + const link = path.join(dir, "link.json") + const referent = path.join(dir, "history_item.json") + currentLink = link + // The doubles say the path is a link to a referent, so a key that resolved the referent is + // distinguishable from the link path itself. + mockedRealpath.mockImplementation(async (target) => (target === link ? referent : String(target))) + mockedReadlink.mockImplementation(async (target) => (target === link ? referent : Promise.reject(new Error("not a link")))) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedAcquireFileLock.mockImplementation(async () => async () => {}) + + await safeWriteJson(link, { id: "task-2" }) + + expect(mockedAcquireFileLock).toHaveBeenCalledWith(link) + }) + + it("does not merge from a symlink an unscoped write is not going to publish through", async () => { + // The merge reads the path the caller named. When that path is a link and the write declares + // no confinement scope, the publish replaces the link, so reading through the link would copy + // JSON from outside the requested path into the replacement file. + const dir = await makeDir("merge-link-") + const link = path.join(dir, "link.json") + currentLink = link + // A real document sits at the path, and the doubles report it as a symlink: reading through + // the link would hand the merge that document, which is what this forbids. + await fs.writeFile(link, JSON.stringify({ had: "referent content" }), "utf8") + mockedRealpath.mockImplementation(async (target) => String(target)) + mockedLstat.mockImplementation(async (target) => + target === link ? ({ isSymbolicLink: () => true, isFile: () => false } as unknown as BigIntStats) : symlinkStat(target), + ) + mockedAcquireFileLock.mockImplementation(async () => async () => {}) + const merge = vi.fn((existing: unknown, next: unknown) => ({ had: existing !== null, next })) + + await safeWriteJson(link, { id: "task-3" }, { merge }) + + expect(merge).toHaveBeenCalledWith(null, { id: "task-3" }) + }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 9bac97b92d..cc6b8a4090 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -154,7 +154,12 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // 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) + // The lock key must name the file this write is going to replace. An unscoped write publishes + // over the link itself (see below), so it locks the link path; only a caller that declared a + // confinement scope publishes through the referent and therefore locks the referent. Locking + // the referent while replacing the link lets two writers hold two different locks for one + // publish target - the lost update the lock exists to prevent. + const lockKey = options?.confineTo ? await resolveLockKey(absoluteFilePath) : absoluteFilePath // Confinement, if the caller declared a scope, is checked BEFORE the lock is // taken and before the parent-directory creation below: an out-of-scope target @@ -215,8 +220,27 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // so a throwing merge still releases the lock. if (options?.merge) { let existing: unknown = null + // An unscoped write replaces the link rather than publishing through it, so it must not + // read through the link either: reading the referent and writing the replacement would + // copy JSON from outside the requested path into a file the caller named. The merge sees + // no existing document, exactly as it would if the path did not exist. + let mergeTargetIsLink = false + if (!options?.confineTo) { + try { + mergeTargetIsLink = (await fs.lstat(resolvedTargetPath)).isSymbolicLink() + } catch (linkError: unknown) { + // An absent target is not a link: the read below reports ENOENT and the merge sees no + // document, which is the behaviour callers rely on. + const code = linkError && typeof linkError === "object" && "code" in linkError ? (linkError as { code?: string }).code : undefined + if (code !== "ENOENT") { + throw linkError + } + } + } try { - existing = JSON.parse(await fs.readFile(resolvedTargetPath, "utf8")) + if (!mergeTargetIsLink) { + 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 From aa886ff03da7feb3fab2d4c06cef2f7b6a3f8e85 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 09:23:20 +0800 Subject: [PATCH 63/73] fix(safe-write): match a verified ACE against the whole principal, not its account name The DACL verification I added in the previous push accepted an access-control entry whose principal ended with the current account name. That suffix rule is the same bug class as the substring check it replaced, one layer up: "OTHERDOMAIN\bob" ends with "\bob" and grants a different account than the local "bob" the narrowing granted, so a DACL that was not narrowed to this process could be reported as verified. The redundant `lower === \`${wanted}\``` term repeated the equality test beside it. Fix: build the set of principal names icacls can report for the granted account - the bare account name, plus the same name qualified with COMPUTERNAME (a local account's authority) and USERDOMAIN (a domain account's) - and require full-string equality, case-insensitively because Windows account names are. No prefix or suffix matching: a principal can contain spaces and can carry an authority that is not this one. The environment values are read at call time, so a process whose environment changed is not compared against a stale name. This is a defect I introduced in this PR, fixed here rather than deferred. Two tests, one per direction: - a read-back naming OTHERDOMAIN\ is not accepted, so the publish reports DaclRestoreError instead of calling the narrowing verified; - a read-back naming TESTMACHINE\ in lower case is accepted, because that is what icacls really prints for a local account and rejecting it would fail every real narrowing. Measured: 77 passed in the safeWriteText spec. Negative controls, each restored byte-exactly (2ae3ffa69c): restoring the suffix rule turns exactly the other-domain test red; dropping the qualified forms turns exactly the machine-name test red. tsc --noEmit with a local paths override: 0 errors. eslint . --ext=ts --max-warnings=0 exit 0, no suppression-count change. Port list: _expectedAcePrincipals and the exact-match rule belong to every unit that carries _aclEntriesAreNarrowedTo - U1 (#1910), U6 (#1915), U7 (#1917) and U9 (#1918) - and U1's version must keep its own staging-location rule when the surrounding tests are ported. --- .../__tests__/safeWriteText.spec.ts | 61 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 29 +++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 3b6c82f415..a0799249a2 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -859,6 +859,67 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(DaclRestoreError) }) + it("win32 DACL: a narrowing granted to a same-named account in another domain is not accepted", 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) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + // The read-back names a different domain's account that happens to share the + // local account's name. It is not the principal this process granted. + callback(null, `${argv[0]} OTHERDOMAIN\\${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") + } + return fakeChild + }) + + // Accepting a principal on its account name alone would call this a verified narrowing + // and publish under a DACL that grants someone else. + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(DaclRestoreError) + }) + + it("win32 DACL: a narrowing reported under the machine name is accepted", async () => { + const targetPath = "/tmp/test-dir/target.txt" + // The machine name is fixed here rather than read from the host, so the assertion says + // which authority the report is qualified against. + const savedMachine = process.env.COMPUTERNAME + const savedDomain = process.env.USERDOMAIN + process.env.COMPUTERNAME = "TESTMACHINE" + delete process.env.USERDOMAIN + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + // What icacls really prints for a local account: the machine-qualified name, + // in its own casing. The grant used the bare account name. + callback(null, `${argv[0]} testmachine\\${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") + } + return fakeChild + }) + + // The qualified report has to satisfy the verification, or every real narrowing on a + // local account would be reported as a failure. + try { + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).resolves.toBeUndefined() + } finally { + process.env.COMPUTERNAME = savedMachine + if (savedDomain !== undefined) { + process.env.USERDOMAIN = savedDomain + } + } + }) + 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) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index bb038e10c2..80a274f080 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -217,14 +217,35 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu * "NT AUTHORITY\\SY:\\Users:(RX)" or "DESKTOP\\bob:(I)(F)". Entries are taken from the lines after * the header, so the path - which usually contains the user's own name - can never satisfy the * check on its own. An entry is accepted only when it carries no "(I)" inherited component and its - * principal is exactly the identity the narrowing granted. + * principal is exactly the identity the narrowing granted, compared as a whole name. */ +/** + * The principal names icacls can report for the account the narrowing granted. The grant names a + * bare account name and icacls reports it qualified - with the domain for a domain account, with + * the machine name for a local one - so each qualified form is compared in full. Windows account + * names are case-insensitive, hence the lowercasing. Nothing matches on a prefix or a suffix: a + * principal can contain spaces, and "OTHERDOMAIN\bob" ends with "\bob" while granting a different + * account than the local "bob". + */ +function _expectedAcePrincipals(identity: string): string[] { + const expected = [identity.toLowerCase()] + // The two environment values name the authority the account is qualified against: COMPUTERNAME + // is the machine a local account belongs to, USERDOMAIN the domain of a domain account. Both are + // read at call time so a process that changed them is not compared against a stale name. + const prefixes = [process.env.COMPUTERNAME ?? "", process.env.USERDOMAIN ?? ""] + for (const prefix of prefixes) { + if (prefix !== "") { + expected.push(`${prefix}\\${identity}`.toLowerCase()) + } + } + return expected +} function _aclEntriesAreNarrowedTo(report: string, identity: string): boolean { // Every "principal:(flags)" pair in the report is an entry; the file path that icacls prints // ahead of the first entry is stripped below so it cannot satisfy the check by containing the // user's name. const pattern = /([^:()]+):\(([^()]*)\)/g - const wanted = identity.toLowerCase() + const expectedPrincipals = _expectedAcePrincipals(identity) let count = 0 for (const match of report.matchAll(pattern)) { let principal = match[1].trim() @@ -239,9 +260,7 @@ function _aclEntriesAreNarrowedTo(report: string, identity: string): boolean { if (flags.includes("(I)")) { return false } - const lower = principal.toLowerCase() - const isWanted = lower === wanted || lower === `${wanted}` || lower.endsWith(`\\${wanted}`) || lower.endsWith(`/${wanted}`) - if (!isWanted) { + if (!expectedPrincipals.includes(principal.toLowerCase())) { return false } count++ From 88d654a2655b6cef0e929dbb4b28ecadcbab5278 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 09:47:46 +0800 Subject: [PATCH 64/73] fix(diffview): put the finalization guard on the public reset(), not behind it A guard that protects only an internal path is not a guard: it aims at one object while the operation runs on another. This is the same defect class as the lock key in #1408 naming a different inode than the publish actually replaced - in both cases the check and the work were about different things, so the check could not stop the work. Here the guard sat in a private helper. finalizeSession() checked sessionFinalizationClaimed and awaited finalizationInFlight, and the two teardown paths called it as finalizeSession(() => this.reset()). But reset() is public, and it also set sessionFinalizationClaimed itself - a second place claiming the same thing. Task and every edit tool (Task.ts, ApplyDiffTool, ApplyPatchTool, EditFileTool, EditTool) call diffViewProvider.reset() directly, so a caller that arrived while a finalization was running bypassed the guard and ran a second teardown over a session that was already closing. Fix: the guard now sits on reset() itself - return if the finalization is claimed, join the attempt already running if one is in flight, otherwise run the teardown once and claim it after it completes. The teardown moved to a private performFinalReset() with no claim of its own, and finalizeSession() is gone: the two call sites just await this.reset(). One entry point, one claim, one teardown. The claim still lasts exactly one session: open() clears both fields when a new diff starts, so the many per-tool reset() calls across a provider's life stay finalizable. Test changes, all tightenings: - New test: two concurrent reset() calls on one session. Asserted on the teardown's own side effects (disposeActiveEditorListener, closeAllDiffViews) rather than on a flag, per the rule that a guard is proven by what it stops. - Five existing finalization tests stubbed the public reset() and counted the call. With the guard on the entry point, the entry may legitimately be called more often than the work runs, so they now stub the guarded teardown and still assert exactly one - which is what they meant to say. Measured: 152 passed. Two failures in this spec are pre-existing and unrelated (DEFAULT_WRITE_DELAY_MS pinned to 0 vs the branch's 1000): the same two fail at aa886ff03 with these changes stashed (2 failed | 151 passed there). Negative control: deleting only the in-flight join turns exactly two tests red - the new concurrency test and "revertChanges() finalizes the session once when two cancellations wait on the same pass" - and the mutant was restored byte-exactly (786b227b9c). tsc --noEmit with a local paths override: 0 errors. eslint . --ext=ts --max-warnings=0 exit 0, no suppression-count change. Port list: any unit carrying DiffViewProvider's finalization pair (sessionFinalizationClaimed / finalizationInFlight) needs this move - U6 (#1915) and U7 (#1917) are the candidates; check with git grep for finalizeSession before assuming the same shape. --- src/integrations/editor/DiffViewProvider.ts | 64 ++++++++++--------- .../editor/__tests__/DiffViewProvider.spec.ts | 37 +++++++++-- 2 files changed, 66 insertions(+), 35 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index aa1357797e..bcabc0967e 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1103,7 +1103,7 @@ export class DiffViewProvider { // whose reset belongs to a tool caller that a cancellation or disposal may never let // come back. Whoever gets here first claims it; a second waiter joins the claim // instead of running a second reset. - await this.finalizeSession(() => this.reset()) + await this.reset() return } // Restore any preview tabs the diff evicted, reconstructing the user's @@ -1112,7 +1112,7 @@ export class DiffViewProvider { // Edit is done. The owner of the pass claims the finalization too, so a caller that // waited on this pass cannot also close the session behind it. - await this.finalizeSession(() => this.reset()) + await this.reset() } private async closeAllDiffViews(): Promise { @@ -1223,31 +1223,6 @@ export class DiffViewProvider { return true } - /** - * Run the session's finalization exactly once. The first caller claims it and runs it; - * anyone who arrives while it is running awaits that attempt rather than starting their own, - * and anyone who arrives after it is done does nothing. A pass that owns its own teardown and - * a caller that merely waited for one go through here, which is what keeps "only one caller - * owns reset()" true regardless of how many cancellations piled onto the same pass. - */ - private async finalizeSession(finalize: () => Promise): Promise { - if (this.sessionFinalizationClaimed) { - return - } - if (this.finalizationInFlight) { - await this.finalizationInFlight - return - } - const inFlight = finalize() - this.finalizationInFlight = inFlight - try { - await inFlight - this.sessionFinalizationClaimed = true - } finally { - this.finalizationInFlight = 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 { @@ -1729,10 +1704,39 @@ export class DiffViewProvider { return result } + /** + * Close the session, finalizing it exactly once per session. + * + * The guard sits on this public entry point rather than on an internal helper, because a guard + * that only protects a private path is not a guard: Task and every edit tool call reset() + * directly, and two of them can reach the same session at once. It is the same defect class as + * the lock key in #1408 naming a different inode than the publish replaced - a guard aimed at + * one object while the operation runs on another guards nothing. + * + * The claim lasts one session: open() clears both fields when a new diff starts, so a provider + * reused by the next tool call is finalizable again. + */ async reset(): Promise { - // A reset closes the session whoever calls it through, so the finalization is claimed - // here as well: a teardown waiter that arrives afterwards must not run a second one. - this.sessionFinalizationClaimed = true + if (this.sessionFinalizationClaimed) { + return + } + if (this.finalizationInFlight) { + // Join the attempt already running instead of starting a second teardown. + await this.finalizationInFlight + return + } + const inFlight = this.performFinalReset() + this.finalizationInFlight = inFlight + try { + await inFlight + this.sessionFinalizationClaimed = true + } finally { + this.finalizationInFlight = undefined + } + } + + /** The teardown itself: what one finalization of a session consists of. */ + private async performFinalReset(): Promise { // Dispose touch listeners and cancel any pending deferred scroll BEFORE any // async editor manipulation. closeAllDiffViews() awaits tab-close operations, // so leaving listeners/timers live across that await could let a stale handler diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 09189752d8..7b42c3e756 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3405,7 +3405,9 @@ describe("DiffViewProvider", () => { diffViewProvider["closeAllDiffViews"] = closeAllDiffViews diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["restorePreviewTabs"] = restorePreviewTabs - diffViewProvider["reset"] = reset + // The guard sits on the public reset(), so the stub stands in for the teardown it + // guards: what has to happen exactly once is the teardown, not the call. + diffViewProvider["performFinalReset"] = reset const applyEdit = vi.mocked(vscode.workspace.applyEdit) applyEdit.mockResolvedValue(true) diffViewProvider["relPath"] = "mock-target-file.ts" @@ -3625,7 +3627,9 @@ describe("DiffViewProvider", () => { const reset = vi.fn().mockResolvedValue(undefined) diffViewProvider["closeOwnDiffView"] = closeOwnDiffView diffViewProvider["restorePreviewTabs"] = restorePreviewTabs - diffViewProvider["reset"] = reset + // The guard sits on the public reset(), so the stub stands in for the teardown it + // guards: what has to happen exactly once is the teardown, not the call. + diffViewProvider["performFinalReset"] = reset diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) const applyEdit = vi.mocked(vscode.workspace.applyEdit) applyEdit.mockResolvedValue(true) @@ -3683,7 +3687,9 @@ describe("DiffViewProvider", () => { const reset = vi.fn().mockResolvedValue(undefined) diffViewProvider["restorePreviewTabs"] = restorePreviewTabs diffViewProvider["closeOwnDiffView"] = closeOwnDiffView - diffViewProvider["reset"] = reset + // The guard sits on the public reset(), so the stub stands in for the teardown it + // guards: what has to happen exactly once is the teardown, not the call. + diffViewProvider["performFinalReset"] = reset diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) diffViewProvider["relPath"] = "mock-target-file.ts" @@ -3738,7 +3744,9 @@ describe("DiffViewProvider", () => { const reset = vi.fn().mockResolvedValue(undefined) diffViewProvider["closeOwnDiffView"] = closeOwnDiffView diffViewProvider["restorePreviewTabs"] = restorePreviewTabs - diffViewProvider["reset"] = reset + // The guard sits on the public reset(), so the stub stands in for the teardown it + // guards: what has to happen exactly once is the teardown, not the call. + diffViewProvider["performFinalReset"] = reset diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) diffViewProvider["relPath"] = "mock-target-file.ts" @@ -3784,7 +3792,9 @@ describe("DiffViewProvider", () => { const reset = vi.fn().mockResolvedValue(undefined) diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["restorePreviewTabs"] = restorePreviewTabs - diffViewProvider["reset"] = reset + // The guard sits on the public reset(), so the stub stands in for the teardown it + // guards: what has to happen exactly once is the teardown, not the call. + diffViewProvider["performFinalReset"] = reset diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) diffViewProvider["relPath"] = "mock-target-file.ts" @@ -3808,6 +3818,23 @@ describe("DiffViewProvider", () => { expect(reset).toHaveBeenCalledTimes(1) }) + it("two concurrent resets finalize the session exactly once", async () => { + // The guard sits on the public entry point, which is the shape a cancellation and a + // disposal produce: both call reset() on the same session at the same time. + const disposeListener = vi.fn() + const closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = undefined + diffViewProvider["disposeActiveEditorListener"] = disposeListener + diffViewProvider["closeAllDiffViews"] = closeAllDiffViews + + await Promise.all([diffViewProvider.reset(), diffViewProvider.reset()]) + + // Asserted on the teardown's own side effects rather than on a flag: the second caller + // joined the attempt already running instead of starting a second teardown. + expect(disposeListener).toHaveBeenCalledTimes(1) + expect(closeAllDiffViews).toHaveBeenCalledTimes(1) + }) + it("revertChanges() does not finalize a session that a direct reset already closed", async () => { const realReset = diffViewProvider["reset"].bind(diffViewProvider) let resetCalls = 0 From 84dd3a73d043d06fa0d51fd6a5c17738a228242e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 10:42:48 +0800 Subject: [PATCH 65/73] fix(safe-write-json): keep the referent lock beside the link-path lock on an unscoped publish The lock key, the object a guard checks, and the inode an operation actually replaces have to be the same object. This is the same defect class as the lock key in #1408 and the other direction of what b8090203e fixed in U6: there the lock named the referent while the publish replaced the link, so the link-path lock was missing; here the lock already names the link, so what U8 lacks is the referent lock. The finding, quoted: "Do not replace the referent lock with only the link-path lock, because the existing contract serializes symlink aliases with direct referent writers while the link exists." An unscoped write replaces the link, so the link-path lock names the inode it replaces - that half was already right. A writer that opens the referent by name takes the referent lock, and while only the link-path lock is held the two writes overlap: after this commit resolveLockKey names the link rather than the referent, so a writer that queued behind the referent never meets the writer that replaced the link, and their merge reads overwrite each other. Fix: an unscoped write now holds both locks when the two identities differ. - Acquisition order is the sorted order of the two keys, so two writers approaching the pair from opposite sides cannot each hold one and wait for the other; release is the reverse, and every lock acquired is released even if an earlier release threw. - A failed acquisition releases what it already took before rethrowing: the protected block has not started, so its finally would not run, and a held lock outlives the call until the stale timeout. - A caller that declares confineTo still takes exactly one lock, the referent, because that is the identity it publishes through. - The two keys are compared the way the filesystem would (case-insensitively on win32): resolveLockKey canonicalizes, so byte-for-byte they can name one file twice, and locking a file this call already locked would stall on its own stale timeout. - publishOverLink is computed once and reused for the publish target and the publish call, instead of the same condition being written twice. Cost accepted: a default write now resolves the referent, one more realpath. That is a read of the link target for locking purposes, not a decision to publish through it - the publish target is unchanged, which the new test asserts. Test changes: - New: serializes a writer that names the referent directly while the link still exists - both keys acquired in sorted order, both released in reverse, the DACL capture still issued for the file this write replaces, and the bytes landing on the link while the referent keeps its own content. - Re-pointed: locks the link path and the referent when the caller declared no confinement scope - it previously asserted the link path alone, which is the half the finding says is not enough. - Control: the confined writer test now asserts exactly one lock, which is what stops the second lock from being added unconditionally. - Test-only seam: the two safeWriteJson specs stub child_process.execFile, the one boundary icacls is reached through. On a sandboxed host a real icacls cannot run, so every write failed the restore check and rolled back: 13 of 40 tests in these files were red at 88d654a26 locally while CI was green on both runners, which made the lock behaviour impossible to observe here at all. The DACL semantics are unchanged and stay asserted in safeWriteText.spec.ts, where the runner is the subject under test; the new test additionally asserts the capture was issued with the path this write replaces, so stubbing cannot quietly skip it. Measured: 37 passed, 4 skipped across the two specs (13 of them were unobservable before the stub). Red first: the two tests above were red before this change. Negative control - reducing the key list to the link path alone, U8's pre-fix shape - turns exactly those two red and leaves the confineTo control green; the mutant was restored byte-exactly (3f21aac6e2). tsc --noEmit with a local paths override: 0 errors. eslint . --ext=ts --max-warnings=0 exit 0, no suppression-count change. Port note for the rest of the chain: the fix is not byte-identical across units, because the units differ. U6 (b8090203e) lacked the link-path lock and U8 lacked the referent lock; a later unit carrying either shape needs its own condition read first, not this diff copied. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 562 ++++++++++-------- src/utils/__tests__/safeWriteJson.test.ts | 10 + src/utils/safeWriteJson.ts | 61 +- 3 files changed, 374 insertions(+), 259 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 83a4a73f71..74e913f0d5 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -1,249 +1,313 @@ -// 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 () => {} - }) - - // A confined writer is the one that publishes through the referent, and therefore the one that - // locks it; an unscoped write replaces the link and locks the link path instead. - await safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir }) - - // 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 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. - // A write that declares no confinement scope no longer resolves the publish target at all, - // so the two post-lock resolutions this used to record are gone; the lock key still comes - // from the referent, which is what the test is about. - expect(order.slice(0, 2)).toEqual(["resolve-failed", "lstat"]) - const lockAt = order.indexOf("lock") - expect(order.slice(0, lockAt)).toContain("resolve") - // A confined writer resolves its scope root on both sides of the lock, so how many times - // the resolution ran is not what this test is about: the caller queued behind the peer on - // the referent's lock, and safeWriteText's two staging checks still come last. - expect(order.slice(lockAt).filter((entry) => entry === "lstat")).toHaveLength(2) - expect(order[order.length - 1]).toBe("lstat") - // A confined writer publishes through the referent, which is the file it locked: the bytes - // land on the referent and the link keeps pointing at them. - 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 - } - }) - - // The in-lock resolution only happens for a caller that declared a scope, so the scope is - // declared here: what this test pins is that a rejection inside the protected block still - // releases the lock, and that path is reached through the confined resolution. - await expect(safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir })).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") - - // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. - // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. - // Two unlinks of that path are expected: safeWriteText cleans up the tempPath it was - // handed, and then safeWriteJson runs its own safety net on the same path. Dropping the - // net would leave one, which is exactly what this checks. - const stagedUnlinks = unlinkSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes(".new_")) - expect(stagedUnlinks).toHaveLength(2) - // Only the original failure is reported. - expect(consoleError).toHaveBeenCalledTimes(1) - - renameSpy.mockRestore() - unlinkSpy.mockRestore() - consoleError.mockRestore() -}) - it("locks the link path when the caller declared no confinement scope", async () => { - // The lock key must name the file this write replaces. An unscoped write publishes over the - // link, so locking the referent would let it run beside another writer that locked the link - - // two locks for one publish target, and the lost update the lock exists to prevent. - const dir = await makeDir("lockkey-unscoped-") - const link = path.join(dir, "link.json") - const referent = path.join(dir, "history_item.json") - currentLink = link - // The doubles say the path is a link to a referent, so a key that resolved the referent is - // distinguishable from the link path itself. - mockedRealpath.mockImplementation(async (target) => (target === link ? referent : String(target))) - mockedReadlink.mockImplementation(async (target) => (target === link ? referent : Promise.reject(new Error("not a link")))) - mockedLstat.mockImplementation(async (target) => symlinkStat(target)) - mockedAcquireFileLock.mockImplementation(async () => async () => {}) - - await safeWriteJson(link, { id: "task-2" }) - - expect(mockedAcquireFileLock).toHaveBeenCalledWith(link) - }) - - it("does not merge from a symlink an unscoped write is not going to publish through", async () => { - // The merge reads the path the caller named. When that path is a link and the write declares - // no confinement scope, the publish replaces the link, so reading through the link would copy - // JSON from outside the requested path into the replacement file. - const dir = await makeDir("merge-link-") - const link = path.join(dir, "link.json") - currentLink = link - // A real document sits at the path, and the doubles report it as a symlink: reading through - // the link would hand the merge that document, which is what this forbids. - await fs.writeFile(link, JSON.stringify({ had: "referent content" }), "utf8") - mockedRealpath.mockImplementation(async (target) => String(target)) - mockedLstat.mockImplementation(async (target) => - target === link ? ({ isSymbolicLink: () => true, isFile: () => false } as unknown as BigIntStats) : symlinkStat(target), - ) - mockedAcquireFileLock.mockImplementation(async () => async () => {}) - const merge = vi.fn((existing: unknown, next: unknown) => ({ had: existing !== null, next })) - - await safeWriteJson(link, { id: "task-3" }, { merge }) - - expect(merge).toHaveBeenCalledWith(null, { id: "task-3" }) - }) +// npx vitest run utils/__tests__/safeWriteJson.lockKey.spec.ts + +import { execFile } from "child_process" +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" + +// The Windows DACL helpers shell out to icacls, which cannot run under a sandboxed test host: +// every write would fail the restore check and roll back. Stub that one boundary, the same way +// safeWriteText.spec.ts does. The DACL semantics are asserted there, where the runner is the +// subject under test; here it only has to not explode. +vi.mock("child_process", () => ({ + execFile: vi.fn((cmd, args, opts, cb) => { + if (typeof cb === "function") cb(null) + }), +})) + +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 () => {} + }) + + // A confined writer is the one that publishes through the referent, and therefore the one that + // locks it; an unscoped write replaces the link and locks the link path instead. + await safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir }) + + // 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) + // Exactly one lock: a confined caller publishes through the referent, so the referent lock is + // the one that serializes it, and a second lock would name an identity it does not replace. + expect(mockedAcquireFileLock).toHaveBeenCalledTimes(1) + // 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. + // A write that declares no confinement scope no longer resolves the publish target at all, + // so the two post-lock resolutions this used to record are gone; the lock key still comes + // from the referent, which is what the test is about. + expect(order.slice(0, 2)).toEqual(["resolve-failed", "lstat"]) + const lockAt = order.indexOf("lock") + expect(order.slice(0, lockAt)).toContain("resolve") + // A confined writer resolves its scope root on both sides of the lock, so how many times + // the resolution ran is not what this test is about: the caller queued behind the peer on + // the referent's lock, and safeWriteText's two staging checks still come last. + expect(order.slice(lockAt).filter((entry) => entry === "lstat")).toHaveLength(2) + expect(order[order.length - 1]).toBe("lstat") + // A confined writer publishes through the referent, which is the file it locked: the bytes + // land on the referent and the link keeps pointing at them. + 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 + } + }) + + // The in-lock resolution only happens for a caller that declared a scope, so the scope is + // declared here: what this test pins is that a rejection inside the protected block still + // releases the lock, and that path is reached through the confined resolution. + await expect(safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir })).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") + + // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. + // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. + // Two unlinks of that path are expected: safeWriteText cleans up the tempPath it was + // handed, and then safeWriteJson runs its own safety net on the same path. Dropping the + // net would leave one, which is exactly what this checks. + const stagedUnlinks = unlinkSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes(".new_")) + expect(stagedUnlinks).toHaveLength(2) + // Only the original failure is reported. + expect(consoleError).toHaveBeenCalledTimes(1) + + renameSpy.mockRestore() + unlinkSpy.mockRestore() + consoleError.mockRestore() +}) + it("locks the link path and the referent when the caller declared no confinement scope", async () => { + // The lock key must name the file this write replaces. An unscoped write publishes over the + // link, so locking the referent alone would let it run beside another writer that locked the + // link - two locks for one publish target, and the lost update the lock exists to prevent. + // The referent lock is kept alongside it rather than replaced: while the link exists, that is + // the lock serializing this alias against a writer that names the referent directly. + const dir = await makeDir("lockkey-unscoped-") + const link = path.join(dir, "link.json") + const referent = path.join(dir, "history_item.json") + currentLink = link + // The doubles say the path is a link to a referent, so a key that resolved the referent is + // distinguishable from the link path itself. + mockedRealpath.mockImplementation(async (target) => (target === link ? referent : String(target))) + mockedReadlink.mockImplementation(async (target) => (target === link ? referent : Promise.reject(new Error("not a link")))) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + const acquired: string[] = [] + mockedAcquireFileLock.mockImplementation(async (key) => { + acquired.push(String(key)) + return async () => {} + }) + + await safeWriteJson(link, { id: "task-2" }) + + expect(acquired).toEqual([referent, link].sort()) + }) + + it("does not merge from a symlink an unscoped write is not going to publish through", async () => { + // The merge reads the path the caller named. When that path is a link and the write declares + // no confinement scope, the publish replaces the link, so reading through the link would copy + // JSON from outside the requested path into the replacement file. + const dir = await makeDir("merge-link-") + const link = path.join(dir, "link.json") + currentLink = link + // A real document sits at the path, and the doubles report it as a symlink: reading through + // the link would hand the merge that document, which is what this forbids. + await fs.writeFile(link, JSON.stringify({ had: "referent content" }), "utf8") + mockedRealpath.mockImplementation(async (target) => String(target)) + mockedLstat.mockImplementation(async (target) => + target === link ? ({ isSymbolicLink: () => true, isFile: () => false } as unknown as BigIntStats) : symlinkStat(target), + ) + mockedAcquireFileLock.mockImplementation(async () => async () => {}) + const merge = vi.fn((existing: unknown, next: unknown) => ({ had: existing !== null, next })) + + await safeWriteJson(link, { id: "task-3" }, { merge }) + + expect(merge).toHaveBeenCalledWith(null, { id: "task-3" }) + }) +it("serializes a writer that names the referent directly while the link still exists", async () => { + // The finding this covers, quoted: "Do not replace the referent lock with only the link-path + // lock, because the existing contract serializes symlink aliases with direct referent writers + // while the link exists." An unscoped write replaces the link, so the link-path lock is the one + // that names the inode it replaces - but a writer that opens the referent by name takes the + // referent lock, and with only the link-path lock held the two writes overlap and their merge + // reads overwrite each other. + const dir = await makeDir("lockkey-referent-") + const link = path.join(dir, "link.json") + const referent = path.join(dir, "history_item.json") + currentLink = link + await fs.writeFile(referent, JSON.stringify({ had: "referent content" }), "utf8") + // The link itself has to exist for real: the DACL capture only runs over an existing target, and + // asserting the stub was called is what proves stubbing icacls did not skip the capture. + await fs.writeFile(link, JSON.stringify({ had: "link content" }), "utf8") + + const acquired: string[] = [] + const released: string[] = [] + mockedRealpath.mockImplementation(async (target) => (target === link ? referent : String(target))) + mockedReadlink.mockImplementation(async (target) => (target === link ? referent : Promise.reject(new Error("not a link")))) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedAcquireFileLock.mockImplementation(async (key) => { + acquired.push(String(key)) + return async () => { + released.push(String(key)) + } + }) + + await safeWriteJson(link, { id: "task-4" }) + + // Both identities, in sorted key order, so two writers approaching the pair from opposite sides + // cannot each hold one and wait for the other; both released, in reverse. + expect(acquired).toEqual([referent, link].sort()) + expect(released).toEqual([...acquired].reverse()) + + // The DACL boundary is still exercised on the file this write replaces: stubbing icacls to keep + // the sandboxed host from failing the restore must not skip the capture/restore calls themselves. + expect(execFile).toHaveBeenCalledWith("icacls", expect.arrayContaining([link, "/save"]), expect.anything(), expect.any(Function)) + + // Taking the extra lock must not move the publish target: the bytes land on the link, and the + // referent keeps the content its own writer put there. + expect(JSON.parse(await fs.readFile(link, "utf8"))).toEqual({ id: "task-4" }) + expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ had: "referent content" }) +}) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 8c23f8f63e..df1504ede5 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -3,6 +3,16 @@ import { Writable } from "stream" import * as path from "path" import * as os from "os" +// The Windows DACL helpers shell out to icacls, which cannot run under a sandboxed test host: +// every write would fail the restore check and roll back. Stub that one boundary, the same way +// safeWriteText.spec.ts does. The DACL semantics are asserted there, where the runner is the +// subject under test; here it only has to not explode. +vi.mock("child_process", () => ({ + execFile: vi.fn((cmd, args, opts, cb) => { + if (typeof cb === "function") cb(null) + }), +})) + import { ConfinedPathEscapeError, safeWriteJson } from "../safeWriteJson" import * as lockfile from "proper-lockfile" diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index cc6b8a4090..f6efdce62d 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -140,7 +140,8 @@ function _scopeErrorCode(error: unknown): string | undefined { */ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) - let releaseLock = async () => {} // Initialized to a no-op + // One release per lock acquired, kept in acquisition order and released in reverse below. + let releaseLocks: Array<() => Promise> = [] // For directory creation const dirPath = path.dirname(absoluteFilePath) @@ -159,7 +160,29 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // confinement scope publishes through the referent and therefore locks the referent. Locking // the referent while replacing the link lets two writers hold two different locks for one // publish target - the lost update the lock exists to prevent. - const lockKey = options?.confineTo ? await resolveLockKey(absoluteFilePath) : absoluteFilePath + const publishOverLink = options?.confineTo === undefined + const referentLockKey = await resolveLockKey(absoluteFilePath) + // A confined caller publishes through the referent, so the referent is the identity it replaces + // and the only lock it needs. A default write replaces the link itself, so the link path is the + // identity it replaces - and it also takes the referent lock, because while the link exists that + // is the lock serializing this alias against a writer that names the referent directly. Holding + // only the link-path lock lets those two writers overlap, and their merge reads overwrite each + // other. Resolving the referent here costs one more realpath on a default write; it is a read of + // the link target for locking purposes, not a decision to publish through it. + // resolveLockKey canonicalizes and Windows paths are case-insensitive, so the two keys are + // compared the same way the filesystem would: byte-for-byte they can name one file twice, and + // locking a file that this call already locked would stall on its own stale timeout. + const linkPathLockKey = absoluteFilePath + const sameIdentity = + process.platform === "win32" + ? referentLockKey.toLowerCase() === linkPathLockKey.toLowerCase() + : referentLockKey === linkPathLockKey + const lockKeys = publishOverLink + ? sameIdentity + ? [linkPathLockKey] + : [referentLockKey, linkPathLockKey].sort() + : [referentLockKey] + const lockKey = lockKeys[lockKeys.length - 1] // Confinement, if the caller declared a scope, is checked BEFORE the lock is // taken and before the parent-directory creation below: an out-of-scope target @@ -172,7 +195,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // 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) + _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(referentLockKey), scopeRoot) } try { @@ -187,7 +210,22 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // immediately, and releaseLock stays a no-op so the finally block does not try // to release an unacquired lock. - releaseLock = await acquireFileLock(lockKey) + // Acquired in sorted key order, so two writers approaching the same pair of identities from + // opposite sides cannot each hold one and wait for the other. If an acquisition fails, anything + // already acquired is released here: the protected block has not started, so its finally would + // not run, and a held lock outlives this call until the stale timeout. + const acquired: Array<() => Promise> = [] + try { + for (const key of lockKeys) { + acquired.push(await acquireFileLock(key)) + } + } catch (lockError) { + for (const release of [...acquired].reverse()) { + await release().catch(() => undefined) + } + throw lockError + } + releaseLocks = acquired // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null @@ -273,7 +311,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso backup: true, // A write with no declared scope replaces the link rather than writing through it, so the // publish must not re-resolve the path it is handed below. - publishOverLink: options?.confineTo === undefined, + publishOverLink, } await safeWriteText(resolvedTargetPath, "", textOptions) @@ -316,11 +354,14 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw originalError // This MUST be the error that rejects the promise. } finally { - // Release the lock in the main finally block. - try { - await releaseLock() - } catch (unlockError) { - console.error(`Failed to release lock for ${resolvedTargetPath ?? absoluteFilePath}:`, unlockError) + // Release in the reverse of the acquisition order, and release every lock that was acquired + // even if an earlier release threw: a lock this call took must not be left held. + for (const release of [...releaseLocks].reverse()) { + try { + await release() + } catch (unlockError) { + console.error(`Failed to release lock for ${resolvedTargetPath ?? absoluteFilePath}:`, unlockError) + } } } } From 2d6c62093f4d8b3f2720283756673732ba38fa43 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 12:23:04 +0800 Subject: [PATCH 66/73] style: format the ten files the formatting gate flags on this PR The compile job's Check formatting step runs 'prettier --check .' and lists 10 files here (job 114112508335). The list is taken from the job log with the ANSI codes stripped first - the escape sequence sits between the bracket and the word, so a search for '[warn]' matches nothing - and the parsed count is checked against the log's own 'Code style issues found in 10 files' line rather than trusted. All ten are inside this PR's own diff; eslint-suppressions.json is not among them, so no suppression count is involved. Formatting only, verified as such: prettier --check passes on all ten; tsc --noEmit with the local paths override reports 0 errors; eslint . --ext=ts --max-warnings=0 exits 0; eslint-suppressions.json untouched. Three tests fail in this worktree: DiffViewProvider saveChanges default write delay x2 (the known DEFAULT_WRITE_DELAY_MS junction difference) and the integration test that publishes through a real rename with no mocking (real icacls cannot run under this sandbox, so the DACL path fails locally while CI is green). Classified rather than waved at: the same two spec files were run with the formatting stashed and unstashed and the failure set is identical by name and by count, so this commit neither introduced nor hid any of them. --- src/core/tools/ApplyDiffTool.ts | 1 - .../applyDiffTool.guardedWrite.spec.ts | 36 +- src/integrations/editor/DiffViewProvider.ts | 4 +- .../editor/__tests__/DiffViewProvider.spec.ts | 31 +- .../safeWriteText.integration.spec.ts | 33 +- .../__tests__/safeWriteText.spec.ts | 235 ++++--- src/services/file-safety/safeWriteText.ts | 26 +- .../__tests__/safeWriteJson.lockKey.spec.ts | 641 +++++++++--------- src/utils/__tests__/safeWriteJson.test.ts | 18 +- src/utils/safeWriteJson.ts | 14 +- 10 files changed, 559 insertions(+), 480 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 98e642a6c8..eeca26c198 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -96,7 +96,6 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } } - // Apply the diff to the original content const diffResult = (await task.diffStrategy?.applyDiff( originalContent, diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 3aaa0c11e1..c91d86bbe3 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -75,9 +75,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { // 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, - ) + stat.mockResolvedValue({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats) }) beforeEach(() => { @@ -230,9 +228,7 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { }) const observation = mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts")) - expect(observation?.version).toBe( - "1:2:22:100:100", - ) + 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) }) @@ -241,9 +237,19 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { // 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 } as unknown as BigIntStats) - .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 30n, mtimeNs: 200n, ctimeNs: 100n } as unknown as BigIntStats) + stat.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, @@ -327,9 +333,15 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { const stat = vi.mocked((await import("fs/promises")).default.stat) // Queue the pre-read stat as a success and reject only the second one, so the branch this // test is about - the post-read bracket - is the one that fails. - stat - .mockResolvedValueOnce({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats) - .mockRejectedValueOnce(Object.assign(new Error("EBUSY: resource busy"), { code: "EBUSY" })) + stat.mockResolvedValueOnce({ + dev: 1n, + ino: 2n, + size: 22n, + mtimeNs: 100n, + ctimeNs: 100n, + } as unknown as BigIntStats).mockRejectedValueOnce( + Object.assign(new Error("EBUSY: resource busy"), { code: "EBUSY" }), + ) await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { askApproval: mockAskApproval, diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bcabc0967e..7e063be4c2 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -858,7 +858,8 @@ export class DiffViewProvider { absolutePath, this.userTouchedDiffEditor, saveState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, saveState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, ) @@ -883,7 +884,6 @@ export class DiffViewProvider { return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } } - // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only // report new problems that are a direct result of this specific edit. diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 7b42c3e756..0dc5394400 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1421,7 +1421,9 @@ describe("DiffViewProvider", () => { // 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) + callback({ + uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" }, + } as unknown as vscode.TextDocument) }, 0) return { dispose: vi.fn() } }) @@ -1450,9 +1452,7 @@ describe("DiffViewProvider", () => { // 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/, - ) + 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) @@ -1725,7 +1725,11 @@ describe("DiffViewProvider", () => { ctimeNs: BigInt(5_000_000_000n), } as unknown as BigIntStats const movedAway = { ...emptyOurs, mtimeNs: BigInt(4_000_000_005n) } as unknown as BigIntStats - const nowFilled = { ...emptyOurs, size: BigInt(24), mtimeNs: BigInt(4_000_000_007n) } as unknown as BigIntStats + const nowFilled = { + ...emptyOurs, + size: BigInt(24), + mtimeNs: BigInt(4_000_000_007n), + } as unknown as BigIntStats vi.mocked(fs.stat).mockResolvedValueOnce(emptyOurs).mockResolvedValueOnce(movedAway) vi.mocked(fs.stat).mockResolvedValue(nowFilled) vi.mocked(fs.readFile).mockResolvedValue("") @@ -2472,11 +2476,7 @@ describe("DiffViewProvider", () => { mtimeNs: BigInt(4_000_000_001n), ctimeNs: BigInt(5_000_000_000n), } as unknown as BigIntStats - mockTask.observationRegistry.observe( - `${mockCwd}/ext-writer.ts`, - versionTokenOfStat(previewStats), - true, - ) + mockTask.observationRegistry.observe(`${mockCwd}/ext-writer.ts`, versionTokenOfStat(previewStats), true) vi.mocked(fs.stat).mockResolvedValue(externalStats) vi.mocked(fs.readFile).mockResolvedValue("external bytes") @@ -2494,9 +2494,9 @@ describe("DiffViewProvider", () => { // No adoption: the observation is not moved onto the state that was clobbered, // so the model's next write is not authorized by it. - expect( - mockTask.observationRegistry.get(`${mockCwd}/ext-writer.ts`)?.version, - ).toBe(versionTokenOfStat(previewStats)) + expect(mockTask.observationRegistry.get(`${mockCwd}/ext-writer.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) }) it("does not adopt a match for an update over a target the model never read", async () => { @@ -2545,9 +2545,7 @@ describe("DiffViewProvider", () => { // The rejection is the outcome: under the old gate the byte match was adopted // and saveChanges resolved as a success, granting the model a clean result for // a write that never happened. - expect( - mockTask.observationRegistry.get(`${mockCwd}/unread-target.ts`)?.complete, - ).toBe(false) + expect(mockTask.observationRegistry.get(`${mockCwd}/unread-target.ts`)?.complete).toBe(false) }) it("does not adopt an autosaved match for an edit that was never authorized", async () => { @@ -3668,7 +3666,6 @@ describe("DiffViewProvider", () => { expect(reset).toHaveBeenCalledTimes(1) }) - // The row: the rejected save's pass ended at closeOwnDiffView(), and the preview tabs were // restored after the pass was over. A cancellation that landed in that window started a // second teardown pass over a session the first pass had already closed, and restored the diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts index efc8937d52..f3e005fabb 100644 --- a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -33,26 +33,23 @@ describe("safeWriteText against a real filesystem", () => { expect(await fs.readdir(dir)).toEqual(["target.txt"]) }) - it.each([false, true])( - "leaves the target bytes untouched when the publish fails (backup: %s)", - async (backup) => { - // backup:false reaches the commit rename, which cannot replace a directory with - // a regular file (EISDIR on POSIX, EPERM on Windows). backup:true fails earlier, - // at the copy of the directory made for the backup. Both must leave the target - // exactly as it was, with no residue. - const targetPath = path.join(dir, "target-dir") - await fs.mkdir(targetPath) - const inside = path.join(targetPath, "payload.txt") - await fs.writeFile(inside, "original bytes") + it.each([false, true])("leaves the target bytes untouched when the publish fails (backup: %s)", async (backup) => { + // backup:false reaches the commit rename, which cannot replace a directory with + // a regular file (EISDIR on POSIX, EPERM on Windows). backup:true fails earlier, + // at the copy of the directory made for the backup. Both must leave the target + // exactly as it was, with no residue. + 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 })).rejects.toThrow() + await expect(safeWriteText(targetPath, "new data", { backup })).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"]) - }, - ) + // 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"]) + }) // The row asks for an integration case: what a real icacls leaves on a real file when the // saved DACL cannot be reapplied. A throwaway temp directory is exactly where that happens - diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a0799249a2..8dc5291824 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -292,20 +292,20 @@ describe("safeWriteText", () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - // Only the restore fails, so a warning is delivered and the restrictive fallback - // (drop inheritance, grant the current user, read the DACL back) verifies. - vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { - const argv = args as unknown as string[] - const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void - if (argv[1] === "/restore") { - callback(new Error("icacls restore error"), "", "") - } else if (argv[1] === undefined) { - callback(null, `${argv[0]} ${os.userInfo().username}:(F)`, "") - } else { - callback(null, "", "") - } - return fakeChild - }) + // Only the restore fails, so a warning is delivered and the restrictive fallback + // (drop inheritance, grant the current user, read the DACL back) verifies. + vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + callback(null, `${argv[0]} ${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") + } + return fakeChild + }) const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) await expect( @@ -320,9 +320,7 @@ describe("safeWriteText", () => { 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"), - ) + expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("onWarning callback rejected")) consoleWarn.mockRestore() }) @@ -415,7 +413,9 @@ describe("safeWriteText", () => { return 1 }) - await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow(PostCommitDurabilityError) + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow( + PostCommitDurabilityError, + ) // 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. @@ -490,7 +490,9 @@ describe("safeWriteText", () => { }) expect(seedOpen).toBeDefined() expect(seedOpen?.[2]).toBe(0o600) - const seedOrder = vi.mocked(fsSync.openSync).mock.invocationCallOrder[vi.mocked(fsSync.openSync).mock.calls.indexOf(seedOpen!)] + 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 @@ -516,14 +518,18 @@ describe("safeWriteText", () => { // 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.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") } }) - await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EIO") + 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. @@ -547,7 +553,9 @@ describe("safeWriteText", () => { // succeeded, or this file outlives the failed write beside the target. vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("EIO"), { code: "EIO" })) - await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow("EIO") + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow( + "EIO", + ) // Nothing was published, and the half-written copy is removed rather than left // next to the target looking like a backup someone could restore. @@ -591,12 +599,22 @@ describe("safeWriteText", () => { // release a descriptor a concurrent open has meanwhile been handed. The failure is // reported where it happened, and the backup - which is what this descriptor was for - // is complete, so the write itself is not failed by it. - await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).resolves.toBeUndefined() + await expect( + safeWriteText(targetPath, "new data", { backup: true, platform: "linux" }), + ).resolves.toBeUndefined() - const seedAttempts = vi.mocked(fsSync.closeSync).mock.calls.filter((call) => Number(call[0]) === seedFd).length + const seedAttempts = vi + .mocked(fsSync.closeSync) + .mock.calls.filter((call) => Number(call[0]) === seedFd).length expect(seedAttempts).toBe(1) - expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("backup seed descriptor for"), expect.anything()) - expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("backup seed descriptor"), expect.anything()) + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("backup seed descriptor for"), + expect.anything(), + ) + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("backup seed descriptor"), + expect.anything(), + ) // Every descriptor this write opened was handed to closeSync: no handle outlives the // failed backup. for (const fd of openedFds) { @@ -682,50 +700,50 @@ describe("safeWriteText", () => { expect(execFile).toHaveBeenCalledTimes(1) }) - it("win32: refuses the write without an onWarning notice 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[] = [] - - // The refusal carries what the warning used to say: the caller learns why the save did - // not happen, and the target keeps the ACL it already had. - await expect( - safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }), - ).rejects.toThrow(DaclCaptureError) - expect(fs.rename).not.toHaveBeenCalled() - expect(warnings).toHaveLength(0) - }) + it("win32: refuses the write without an onWarning notice 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[] = [] - 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" }) - } + // The refusal carries what the warning used to say: the caller learns why the save did + // not happen, and the target keeps the ACL it already had. + await expect( + safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }), + ).rejects.toThrow(DaclCaptureError) + expect(fs.rename).not.toHaveBeenCalled() + expect(warnings).toHaveLength(0) }) - const warnings: string[] = [] - await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + 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[] = [] - expect(execFile).not.toHaveBeenCalled() - expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) - }) + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) - // 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) - // Only the restore fails, so a warning is delivered and the restrictive fallback + expect(execFile).not.toHaveBeenCalled() + expect(warnings.filter((m) => m.includes("Could not check"))).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) + // Only the restore fails, so a warning is delivered and the restrictive fallback // (drop inheritance, grant the current user, read the DACL back) verifies. vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { const argv = args as unknown as string[] @@ -739,18 +757,18 @@ describe("safeWriteText", () => { } 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) - }) + + 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" @@ -778,7 +796,6 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) - // The row's second half: "If DACL restoration fails after commit, restore a restrictive // verified ACL before returning, or otherwise fail safely without exposing the committed // content." These three cases are the two branches and the verification step. @@ -800,10 +817,14 @@ describe("safeWriteText", () => { return fakeChild }) - await expect(safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) })).resolves.toBeUndefined() + await expect( + safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }), + ).resolves.toBeUndefined() // The narrowing was actually applied to the committed file, not just reported. - const grant = vi.mocked(execFile).mock.calls.find(([, a]) => (a as unknown as string[])[1] === "/inheritance:r") + const grant = vi + .mocked(execFile) + .mock.calls.find(([, a]) => (a as unknown as string[])[1] === "/inheritance:r") expect(grant).toBeDefined() expect((grant?.[1] as unknown as string[]) ?? []).toEqual([ targetPath, @@ -811,7 +832,9 @@ describe("safeWriteText", () => { "/grant:r", `${os.userInfo().username}:F`, ]) - expect(warnings.filter((m) => m.includes("narrowed to the current user's full control and verified"))).toHaveLength(1) + expect( + warnings.filter((m) => m.includes("narrowed to the current user's full control and verified")), + ).toHaveLength(1) }) it("win32 DACL: a restore failure the narrowing cannot recover from rolls the commit back", async () => { @@ -829,7 +852,9 @@ describe("safeWriteText", () => { return fakeChild }) - await expect(safeWriteText(targetPath, "data", { backup: true, platform: "win32" })).rejects.toThrow(DaclRestoreError) + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "win32" })).rejects.toThrow( + DaclRestoreError, + ) // The committed bytes do not stay at the target under an unknown ACL: the retained // backup is renamed back, so the second rename is the rollback. @@ -1551,9 +1576,9 @@ describe("caller-supplied staging path", () => { const stats = _fileStatsWithIdentity(42n, 7n) vi.mocked(fs.lstat).mockResolvedValue(stats) - await expect( - safeWriteText(targetPath, "data", { tempPath: targetPath, platform: "linux" }), - ).rejects.toThrow(StagingPathError) + await expect(safeWriteText(targetPath, "data", { tempPath: targetPath, platform: "linux" })).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 @@ -1568,7 +1593,6 @@ describe("caller-supplied staging path", () => { 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) @@ -1595,7 +1619,8 @@ describe("caller-supplied staging path", () => { for (const c of identityLookups) { expect(c[1]).toEqual({ bigint: true }) } - })}) + }) +}) describe("cleanup when a backed-up write fails before commit", () => { beforeEach(() => mockDefaults()) @@ -1690,7 +1715,9 @@ describe("resolveLockKey when the parent directory does not exist yet", () => { vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) // Not a link: the walk must reach the canonicalization step, which is where the // non-ENOENT failure has to surface. - vi.mocked(fs.readlink).mockRejectedValue(Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })) + vi.mocked(fs.readlink).mockRejectedValue( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) vi.mocked(fs.realpath).mockImplementation(async (p) => { if (String(p) === target) return target throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) @@ -1712,7 +1739,9 @@ describe("descriptor release is attempted once and reported", () => { beforeEach(() => { mockDefaults() // The staging write reports every requested byte as written. - vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => (typeof args[3] === "number" ? args[3] : 0)) + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + typeof args[3] === "number" ? args[3] : 0, + ) vi.mocked(fs.realpath).mockResolvedValue(targetPath) }) @@ -1745,7 +1774,9 @@ describe("descriptor release is attempted once and reported", () => { }) const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - await expect(safeWriteText(targetPath, "data", { platform: "linux", tempPath: callerTemp })).resolves.toBeUndefined() + await expect( + safeWriteText(targetPath, "data", { platform: "linux", tempPath: callerTemp }), + ).resolves.toBeUndefined() const closedFds = vi.mocked(fsSync.closeSync).mock.calls.map((c) => Number(c[0])) expect(new Set(closedFds).size).toBe(closedFds.length) @@ -1789,7 +1820,10 @@ describe("descriptor release is attempted once and reported", () => { await expect(safeWriteText(targetPath, "data", { platform: "linux" })).resolves.toBeUndefined() expect(vi.mocked(fsSync.closeSync).mock.calls.filter((c) => c[0] === 7)).toHaveLength(1) - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("parent directory descriptor for"), expect.anything()) + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining("parent directory descriptor for"), + expect.anything(), + ) errSpy.mockRestore() }) }) @@ -1799,14 +1833,21 @@ describe("windows ACL failure leaves no unauthorized content", () => { beforeEach(() => { mockDefaults() - vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => (typeof args[3] === "number" ? args[3] : 0)) + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + typeof args[3] === "number" ? args[3] : 0, + ) vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(3) }) // icacls is dispatched by its arguments so only the operation under test can be made to fail. const icacls = (handler: (argv: string[]) => { error?: unknown; stdout?: string }) => - vi.mocked(execFile).mockImplementation(((_cmd: string, argv: string[], _opts: unknown, cb: (err: unknown, stdout?: string) => void) => { + vi.mocked(execFile).mockImplementation((( + _cmd: string, + argv: string[], + _opts: unknown, + cb: (err: unknown, stdout?: string) => void, + ) => { const outcome = handler(argv) return void cb(outcome.error ?? null, outcome.stdout ?? "") }) as never) @@ -1837,11 +1878,15 @@ describe("windows ACL failure leaves no unauthorized content", () => { // exists even though this caller passed no backup option. expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) expect( - vi.mocked(fs.rename).mock.calls.some((call) => String(call[0]).includes("safeWriteText.bak_") && call[1] === targetPath), + vi + .mocked(fs.rename) + .mock.calls.some((call) => String(call[0]).includes("safeWriteText.bak_") && call[1] === targetPath), ).toBe(true) // The rolled-back file is the backup, whose access rights came from its directory rather than // from the original target, so the verified narrowing runs on it too. - expect(grants.filter((argv) => argv[0] === targetPath && argv.includes("/grant:r")).length).toBeGreaterThanOrEqual(2) + expect( + grants.filter((argv) => argv[0] === targetPath && argv.includes("/grant:r")).length, + ).toBeGreaterThanOrEqual(2) }) it("keeps the retained backup when the rollback rename itself fails", async () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 80a274f080..5376d7cbf6 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -196,7 +196,11 @@ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunne /** Restore a DACL dump onto *dirPath* on Windows. * Returns whether icacls succeeded; the caller reports a failure. */ -async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { +async function _restoreDaclWindows( + dirPath: string, + dumpPath: string, + execFileRunner?: typeof execFile, +): Promise { const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { @@ -290,7 +294,9 @@ async function _restrictDaclWindows( return false } const readBack = await new Promise((resolve) => { - runner("icacls", [filePath], { windowsHide: true }, (err, stdout) => (err ? resolve(null) : resolve(stdout ?? ""))) + runner("icacls", [filePath], { windowsHide: true }, (err, stdout) => + err ? resolve(null) : resolve(stdout ?? ""), + ) }) if (readBack === null) { return false @@ -652,7 +658,9 @@ export async function safeWriteText( // 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.`) + warn( + `Could not check ${targetPath} for DACL preservation (${errorCode(accessError) ?? "unknown error"}); the replacement may inherit different access rights.`, + ) } } try { @@ -802,7 +810,9 @@ export async function safeWriteText( try { await fs.rename(rollbackPath, targetPath) } catch (rollbackError: unknown) { - warn(`safeWriteText: the DACL of ${targetPath} could not be restored and its content could not be rolled back; the pre-write content is retained at ${rollbackPath}.`) + warn( + `safeWriteText: the DACL of ${targetPath} could not be restored and its content could not be rolled back; the pre-write content is retained at ${rollbackPath}.`, + ) throw new DaclRestoreError(targetPath, rollbackError) } // The rolled-back file is the backup, whose access rights came from the @@ -811,12 +821,16 @@ export async function safeWriteText( // finished its job either. const rolledBackAcl = await _restrictDaclWindows(targetPath, options?.execFileRunner) if (!rolledBackAcl) { - warn(`safeWriteText: the previous content is back at ${targetPath}, but its access rights could not be narrowed or verified; the file carries the access rights of its directory.`) + warn( + `safeWriteText: the previous content is back at ${targetPath}, but its access rights could not be narrowed or verified; the file carries the access rights of its directory.`, + ) } } throw new DaclRestoreError(targetPath) } - warn(`safeWriteText: content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file's access rights were narrowed to the current user's full control and verified.`) + warn( + `safeWriteText: content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file's access rights were narrowed to the current user's full control and verified.`, + ) } } diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 74e913f0d5..d3ffb68cd4 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -1,313 +1,328 @@ -// npx vitest run utils/__tests__/safeWriteJson.lockKey.spec.ts - -import { execFile } from "child_process" -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" - -// The Windows DACL helpers shell out to icacls, which cannot run under a sandboxed test host: -// every write would fail the restore check and roll back. Stub that one boundary, the same way -// safeWriteText.spec.ts does. The DACL semantics are asserted there, where the runner is the -// subject under test; here it only has to not explode. -vi.mock("child_process", () => ({ - execFile: vi.fn((cmd, args, opts, cb) => { - if (typeof cb === "function") cb(null) - }), -})) - -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 () => {} - }) - - // A confined writer is the one that publishes through the referent, and therefore the one that - // locks it; an unscoped write replaces the link and locks the link path instead. - await safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir }) - - // 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) - // Exactly one lock: a confined caller publishes through the referent, so the referent lock is - // the one that serializes it, and a second lock would name an identity it does not replace. - expect(mockedAcquireFileLock).toHaveBeenCalledTimes(1) - // 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. - // A write that declares no confinement scope no longer resolves the publish target at all, - // so the two post-lock resolutions this used to record are gone; the lock key still comes - // from the referent, which is what the test is about. - expect(order.slice(0, 2)).toEqual(["resolve-failed", "lstat"]) - const lockAt = order.indexOf("lock") - expect(order.slice(0, lockAt)).toContain("resolve") - // A confined writer resolves its scope root on both sides of the lock, so how many times - // the resolution ran is not what this test is about: the caller queued behind the peer on - // the referent's lock, and safeWriteText's two staging checks still come last. - expect(order.slice(lockAt).filter((entry) => entry === "lstat")).toHaveLength(2) - expect(order[order.length - 1]).toBe("lstat") - // A confined writer publishes through the referent, which is the file it locked: the bytes - // land on the referent and the link keeps pointing at them. - 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 - } - }) - - // The in-lock resolution only happens for a caller that declared a scope, so the scope is - // declared here: what this test pins is that a rejection inside the protected block still - // releases the lock, and that path is reached through the confined resolution. - await expect(safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir })).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") - - // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. - // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. - // Two unlinks of that path are expected: safeWriteText cleans up the tempPath it was - // handed, and then safeWriteJson runs its own safety net on the same path. Dropping the - // net would leave one, which is exactly what this checks. - const stagedUnlinks = unlinkSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes(".new_")) - expect(stagedUnlinks).toHaveLength(2) - // Only the original failure is reported. - expect(consoleError).toHaveBeenCalledTimes(1) - - renameSpy.mockRestore() - unlinkSpy.mockRestore() - consoleError.mockRestore() -}) - it("locks the link path and the referent when the caller declared no confinement scope", async () => { - // The lock key must name the file this write replaces. An unscoped write publishes over the - // link, so locking the referent alone would let it run beside another writer that locked the - // link - two locks for one publish target, and the lost update the lock exists to prevent. - // The referent lock is kept alongside it rather than replaced: while the link exists, that is - // the lock serializing this alias against a writer that names the referent directly. - const dir = await makeDir("lockkey-unscoped-") - const link = path.join(dir, "link.json") - const referent = path.join(dir, "history_item.json") - currentLink = link - // The doubles say the path is a link to a referent, so a key that resolved the referent is - // distinguishable from the link path itself. - mockedRealpath.mockImplementation(async (target) => (target === link ? referent : String(target))) - mockedReadlink.mockImplementation(async (target) => (target === link ? referent : Promise.reject(new Error("not a link")))) - mockedLstat.mockImplementation(async (target) => symlinkStat(target)) - const acquired: string[] = [] - mockedAcquireFileLock.mockImplementation(async (key) => { - acquired.push(String(key)) - return async () => {} - }) - - await safeWriteJson(link, { id: "task-2" }) - - expect(acquired).toEqual([referent, link].sort()) - }) - - it("does not merge from a symlink an unscoped write is not going to publish through", async () => { - // The merge reads the path the caller named. When that path is a link and the write declares - // no confinement scope, the publish replaces the link, so reading through the link would copy - // JSON from outside the requested path into the replacement file. - const dir = await makeDir("merge-link-") - const link = path.join(dir, "link.json") - currentLink = link - // A real document sits at the path, and the doubles report it as a symlink: reading through - // the link would hand the merge that document, which is what this forbids. - await fs.writeFile(link, JSON.stringify({ had: "referent content" }), "utf8") - mockedRealpath.mockImplementation(async (target) => String(target)) - mockedLstat.mockImplementation(async (target) => - target === link ? ({ isSymbolicLink: () => true, isFile: () => false } as unknown as BigIntStats) : symlinkStat(target), - ) - mockedAcquireFileLock.mockImplementation(async () => async () => {}) - const merge = vi.fn((existing: unknown, next: unknown) => ({ had: existing !== null, next })) - - await safeWriteJson(link, { id: "task-3" }, { merge }) - - expect(merge).toHaveBeenCalledWith(null, { id: "task-3" }) - }) -it("serializes a writer that names the referent directly while the link still exists", async () => { - // The finding this covers, quoted: "Do not replace the referent lock with only the link-path - // lock, because the existing contract serializes symlink aliases with direct referent writers - // while the link exists." An unscoped write replaces the link, so the link-path lock is the one - // that names the inode it replaces - but a writer that opens the referent by name takes the - // referent lock, and with only the link-path lock held the two writes overlap and their merge - // reads overwrite each other. - const dir = await makeDir("lockkey-referent-") - const link = path.join(dir, "link.json") - const referent = path.join(dir, "history_item.json") - currentLink = link - await fs.writeFile(referent, JSON.stringify({ had: "referent content" }), "utf8") - // The link itself has to exist for real: the DACL capture only runs over an existing target, and - // asserting the stub was called is what proves stubbing icacls did not skip the capture. - await fs.writeFile(link, JSON.stringify({ had: "link content" }), "utf8") - - const acquired: string[] = [] - const released: string[] = [] - mockedRealpath.mockImplementation(async (target) => (target === link ? referent : String(target))) - mockedReadlink.mockImplementation(async (target) => (target === link ? referent : Promise.reject(new Error("not a link")))) - mockedLstat.mockImplementation(async (target) => symlinkStat(target)) - mockedAcquireFileLock.mockImplementation(async (key) => { - acquired.push(String(key)) - return async () => { - released.push(String(key)) - } - }) - - await safeWriteJson(link, { id: "task-4" }) - - // Both identities, in sorted key order, so two writers approaching the pair from opposite sides - // cannot each hold one and wait for the other; both released, in reverse. - expect(acquired).toEqual([referent, link].sort()) - expect(released).toEqual([...acquired].reverse()) - - // The DACL boundary is still exercised on the file this write replaces: stubbing icacls to keep - // the sandboxed host from failing the restore must not skip the capture/restore calls themselves. - expect(execFile).toHaveBeenCalledWith("icacls", expect.arrayContaining([link, "/save"]), expect.anything(), expect.any(Function)) - - // Taking the extra lock must not move the publish target: the bytes land on the link, and the - // referent keeps the content its own writer put there. - expect(JSON.parse(await fs.readFile(link, "utf8"))).toEqual({ id: "task-4" }) - expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ had: "referent content" }) -}) +// npx vitest run utils/__tests__/safeWriteJson.lockKey.spec.ts + +import { execFile } from "child_process" + +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" + +// The Windows DACL helpers shell out to icacls, which cannot run under a sandboxed test host: +// every write would fail the restore check and roll back. Stub that one boundary, the same way +// safeWriteText.spec.ts does. The DACL semantics are asserted there, where the runner is the +// subject under test; here it only has to not explode. +vi.mock("child_process", () => ({ + execFile: vi.fn((cmd, args, opts, cb) => { + if (typeof cb === "function") cb(null) + }), +})) + +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 () => {} + }) + + // A confined writer is the one that publishes through the referent, and therefore the one that + // locks it; an unscoped write replaces the link and locks the link path instead. + await safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir }) + + // 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) + // Exactly one lock: a confined caller publishes through the referent, so the referent lock is + // the one that serializes it, and a second lock would name an identity it does not replace. + expect(mockedAcquireFileLock).toHaveBeenCalledTimes(1) + // 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. + // A write that declares no confinement scope no longer resolves the publish target at all, + // so the two post-lock resolutions this used to record are gone; the lock key still comes + // from the referent, which is what the test is about. + expect(order.slice(0, 2)).toEqual(["resolve-failed", "lstat"]) + const lockAt = order.indexOf("lock") + expect(order.slice(0, lockAt)).toContain("resolve") + // A confined writer resolves its scope root on both sides of the lock, so how many times + // the resolution ran is not what this test is about: the caller queued behind the peer on + // the referent's lock, and safeWriteText's two staging checks still come last. + expect(order.slice(lockAt).filter((entry) => entry === "lstat")).toHaveLength(2) + expect(order[order.length - 1]).toBe("lstat") + // A confined writer publishes through the referent, which is the file it locked: the bytes + // land on the referent and the link keeps pointing at them. + 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 + } + }) + + // The in-lock resolution only happens for a caller that declared a scope, so the scope is + // declared here: what this test pins is that a rejection inside the protected block still + // releases the lock, and that path is reached through the confined resolution. + await expect(safeWriteJson(currentLink, { id: "task-1" }, { confineTo: dir })).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") + + // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. + // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. + // Two unlinks of that path are expected: safeWriteText cleans up the tempPath it was + // handed, and then safeWriteJson runs its own safety net on the same path. Dropping the + // net would leave one, which is exactly what this checks. + const stagedUnlinks = unlinkSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes(".new_")) + expect(stagedUnlinks).toHaveLength(2) + // Only the original failure is reported. + expect(consoleError).toHaveBeenCalledTimes(1) + + renameSpy.mockRestore() + unlinkSpy.mockRestore() + consoleError.mockRestore() +}) +it("locks the link path and the referent when the caller declared no confinement scope", async () => { + // The lock key must name the file this write replaces. An unscoped write publishes over the + // link, so locking the referent alone would let it run beside another writer that locked the + // link - two locks for one publish target, and the lost update the lock exists to prevent. + // The referent lock is kept alongside it rather than replaced: while the link exists, that is + // the lock serializing this alias against a writer that names the referent directly. + const dir = await makeDir("lockkey-unscoped-") + const link = path.join(dir, "link.json") + const referent = path.join(dir, "history_item.json") + currentLink = link + // The doubles say the path is a link to a referent, so a key that resolved the referent is + // distinguishable from the link path itself. + mockedRealpath.mockImplementation(async (target) => (target === link ? referent : String(target))) + mockedReadlink.mockImplementation(async (target) => + target === link ? referent : Promise.reject(new Error("not a link")), + ) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + const acquired: string[] = [] + mockedAcquireFileLock.mockImplementation(async (key) => { + acquired.push(String(key)) + return async () => {} + }) + + await safeWriteJson(link, { id: "task-2" }) + + expect(acquired).toEqual([referent, link].sort()) +}) + +it("does not merge from a symlink an unscoped write is not going to publish through", async () => { + // The merge reads the path the caller named. When that path is a link and the write declares + // no confinement scope, the publish replaces the link, so reading through the link would copy + // JSON from outside the requested path into the replacement file. + const dir = await makeDir("merge-link-") + const link = path.join(dir, "link.json") + currentLink = link + // A real document sits at the path, and the doubles report it as a symlink: reading through + // the link would hand the merge that document, which is what this forbids. + await fs.writeFile(link, JSON.stringify({ had: "referent content" }), "utf8") + mockedRealpath.mockImplementation(async (target) => String(target)) + mockedLstat.mockImplementation(async (target) => + target === link + ? ({ isSymbolicLink: () => true, isFile: () => false } as unknown as BigIntStats) + : symlinkStat(target), + ) + mockedAcquireFileLock.mockImplementation(async () => async () => {}) + const merge = vi.fn((existing: unknown, next: unknown) => ({ had: existing !== null, next })) + + await safeWriteJson(link, { id: "task-3" }, { merge }) + + expect(merge).toHaveBeenCalledWith(null, { id: "task-3" }) +}) +it("serializes a writer that names the referent directly while the link still exists", async () => { + // The finding this covers, quoted: "Do not replace the referent lock with only the link-path + // lock, because the existing contract serializes symlink aliases with direct referent writers + // while the link exists." An unscoped write replaces the link, so the link-path lock is the one + // that names the inode it replaces - but a writer that opens the referent by name takes the + // referent lock, and with only the link-path lock held the two writes overlap and their merge + // reads overwrite each other. + const dir = await makeDir("lockkey-referent-") + const link = path.join(dir, "link.json") + const referent = path.join(dir, "history_item.json") + currentLink = link + await fs.writeFile(referent, JSON.stringify({ had: "referent content" }), "utf8") + // The link itself has to exist for real: the DACL capture only runs over an existing target, and + // asserting the stub was called is what proves stubbing icacls did not skip the capture. + await fs.writeFile(link, JSON.stringify({ had: "link content" }), "utf8") + + const acquired: string[] = [] + const released: string[] = [] + mockedRealpath.mockImplementation(async (target) => (target === link ? referent : String(target))) + mockedReadlink.mockImplementation(async (target) => + target === link ? referent : Promise.reject(new Error("not a link")), + ) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedAcquireFileLock.mockImplementation(async (key) => { + acquired.push(String(key)) + return async () => { + released.push(String(key)) + } + }) + + await safeWriteJson(link, { id: "task-4" }) + + // Both identities, in sorted key order, so two writers approaching the pair from opposite sides + // cannot each hold one and wait for the other; both released, in reverse. + expect(acquired).toEqual([referent, link].sort()) + expect(released).toEqual([...acquired].reverse()) + + // The DACL boundary is still exercised on the file this write replaces: stubbing icacls to keep + // the sandboxed host from failing the restore must not skip the capture/restore calls themselves. + expect(execFile).toHaveBeenCalledWith( + "icacls", + expect.arrayContaining([link, "/save"]), + expect.anything(), + expect.any(Function), + ) + + // Taking the extra lock must not move the publish target: the bytes land on the link, and the + // referent keeps the content its own writer put there. + expect(JSON.parse(await fs.readFile(link, "utf8"))).toEqual({ id: "task-4" }) + expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ had: "referent content" }) +}) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index df1504ede5..fd99f0fdbe 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -709,16 +709,18 @@ describe("safeWriteJson", () => { const projectConfig = path.join(projectDir, "mcp.json") await fs.symlink(outside, projectConfig) - await expect( - safeWriteJson(projectConfig, { mcpServers: {} }, { confineTo: projectDir }), - ).rejects.toThrow(ConfinedPathEscapeError) + 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")), + entries.filter( + (entry) => entry.includes(".new_") || entry.includes("safeWriteText") || entry.endsWith(".lock"), + ), ).toEqual([]) }, ) @@ -735,7 +737,11 @@ describe("safeWriteJson", () => { // 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 }) + 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" } }, @@ -785,7 +791,6 @@ describe("safeWriteJson", () => { }) }) - test.skipIf(process.platform === "win32")( "confines a scope path that itself runs through a symlink and does not exist yet", async () => { @@ -912,7 +917,6 @@ describe("safeWriteJson", () => { } }) - // The walk-up branch, pinned through the shape CR suggested: the target sits under the same // existing ancestor but OUTSIDE the missing scope. That matters because the two outcomes then // carry different errors - propagating the errno from the ancestor, versus falling back to a diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index f6efdce62d..6b5623ec4a 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -108,12 +108,7 @@ async function _resolveScopeRoot(confineTo: string): Promise { */ 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) - ) { + if (relative === "" || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) { throw new ConfinedPathEscapeError(requestedPath, candidatePath, scopeRoot) } } @@ -206,8 +201,6 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } - - // immediately, and releaseLock stays a no-op so the finally block does not try // to release an unacquired lock. // Acquired in sorted key order, so two writers approaching the same pair of identities from @@ -269,7 +262,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } catch (linkError: unknown) { // An absent target is not a link: the read below reports ENOENT and the merge sees no // document, which is the behaviour callers rely on. - const code = linkError && typeof linkError === "object" && "code" in linkError ? (linkError as { code?: string }).code : undefined + const code = + linkError && typeof linkError === "object" && "code" in linkError + ? (linkError as { code?: string }).code + : undefined if (code !== "ENOENT") { throw linkError } From 5e99a1a26d4f529fb7ef920287606e3e91e2e05f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 13:04:48 +0800 Subject: [PATCH 67/73] test: scope the icacls assertion to the platform that issues the command platform-unit-test (ubuntu-latest) failed at this head: test:coverage:misc reported 'expected "vi.fn()" to be called with arguments: [ icacls, ArrayContaining{...} ]' (1 failed / 107 passed / 2 skipped), and windows was cancelled alongside it. The failing assertion is the one this PR added in the referent-writer test: it required the DACL capture to have been issued for the replaced path. The capture is genuinely windows-only in production: safeWriteText gates the DACL dump on platform === "win32" (line 626), because icacls is a win32 tool. So the assertion was asking a linux runner for a windows command - the test's applicability did not share a source with the condition that runs the command. The DACL semantics themselves stay asserted in safeWriteText.spec.ts, where the runner is the subject and the platform is passed explicitly; the integration spec that shells out to a real icacls is already gated with skipIf(process.platform !== "win32"). This was the one ungated case. The assertion now follows the same condition production uses: on win32 it requires the capture against the replaced path, and on another platform it asserts the opposite - that no DACL command was issued at all. Both directions are load-bearing: flipping the condition to !== makes the test red on either runner (verified on this win32 host: 7 passed with the condition, 1 failed with it flipped, mutant restored byte-exact). Verification: the three safeWriteJson/file-safety specs pass locally; prettier --write then --check with the repo config reports the file clean; tsc --noEmit with the local paths override reports 0 errors; eslint . --ext=ts --max-warnings=0 exits 0; eslint-suppressions.json unchanged. --- .../__tests__/safeWriteJson.lockKey.spec.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index d3ffb68cd4..4e2b7e7038 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -313,13 +313,20 @@ it("serializes a writer that names the referent directly while the link still ex expect(released).toEqual([...acquired].reverse()) // The DACL boundary is still exercised on the file this write replaces: stubbing icacls to keep - // the sandboxed host from failing the restore must not skip the capture/restore calls themselves. - expect(execFile).toHaveBeenCalledWith( - "icacls", - expect.arrayContaining([link, "/save"]), - expect.anything(), - expect.any(Function), - ) + // the sandboxed host from failing the restore must not skip the capture itself. The capture is + // gated on win32 inside safeWriteText, because icacls is a win32 tool, so this assertion follows + // that same condition rather than asking a linux runner for a windows command. On another + // platform the honest expectation is the opposite one: no DACL command at all. + if (process.platform === "win32") { + expect(execFile).toHaveBeenCalledWith( + "icacls", + expect.arrayContaining([link, "/save"]), + expect.anything(), + expect.any(Function), + ) + } else { + expect(execFile).not.toHaveBeenCalled() + } // Taking the extra lock must not move the publish target: the bytes land on the link, and the // referent keeps the content its own writer put there. From 19aec006dbf956191036b43e39e90ba088cc1693 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 13:46:18 +0800 Subject: [PATCH 68/73] fix: fold a lock key from the deepest existing ancestor, not the way strings compare Port of #1915's 5131bc0cd to this unit's shape. platform-unit-test (windows-latest) failed here with four safeWriteJson.test.ts assertions reporting 'expected [Function] to throw error including Primary rename failed but got Lock file is already being held', and the two directory-creation names are the diagnosis: when a component of the target is missing, the previous fold could not reach a canonical form. This unit compared the two lock identities by case alone. On Windows the filesystem folds two spellings of one directory entry in two ways - case anywhere, and short (8.3) names inside a component - and a CI agent hands out its runner profile directory as RUNNER~1, so os.tmpdir() below it is spelled two ways at once. resolveLockKey canonicalises through the highest ancestor it can reach, so a case-only comparison leaves the canonical referent key unequal to the requested short spelling: one .lock directory is asked for twice and the second acquisition collides with the first one's own lock. _lockIdentityKey now canonicalises the deepest EXISTING ancestor and appends the segments below it, case-folded on Windows. The walk starts at the PARENT, not at the file: the lock is the entry .lock beside the file, so the final component must never be resolved through a symlink, or a link and its referent would fold into one key and the two distinct .lock entries this call takes on purpose would collapse into one. That boundary was established on the unit this is ported from, where starting the walk at the file turned three existing tests red; the port is not byte-identical because this unit computed sameIdentity inline. Verification and its limit, stated plainly: the four CI failures do not reproduce on this host, because the local os.tmpdir() has no short-name ancestor - only a CI agent (or a host where 8.3 names are in play) exercises that spelling, so the four are verified by CI, not locally. What is verified locally is that the fold does not disturb anything else: the safeWriteJson specs pass 37 / 4 skipped, and safeWriteText.integration.spec.ts fails the same single test with and without this change (identical by name, checked by stashing the change and re-running), which is the known environment limit - that test runs the real icacls, which cannot run in this sandbox. prettier --write then --check with the repo config clean; tsc --noEmit with the local paths override 0 errors; eslint . --ext=ts --max-warnings=0 exits 0; eslint-suppressions.json unchanged. Still owed by this port, recorded rather than assumed: the mixed-ancestor acceptance test (existing ancestor spelled short with a missing tail) has not been ported yet, because in this unit sameIdentity is only consulted when publishing over a link, so the test must be re-derived against that shape rather than copied. --- src/utils/safeWriteJson.ts | 54 +++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 6b5623ec4a..e0321a9358 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -133,6 +133,44 @@ function _scopeErrorCode(error: unknown): string | undefined { * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ +/** + * Fold a path into the identity its advisory lock is actually placed under. + * + * acquireFileLock locks `.lock` with realpath:false (see fileLock.ts), so the + * lock's identity is the directory ENTRY the path names, not the string. On Windows the + * filesystem folds two spellings of one entry in two ways: case anywhere, and short (8.3) names + * inside a component - the runner profile directory arrives as RUNNER~1 on a CI agent, which is + * where this was first observed. A case-only comparison folds only the first, so the second + * leaves two keys that look different, one .lock directory, and a second acquisition that + * collides with the first one's own lock: 'Lock file is already being held' once the retries are + * spent. + * + * So fold the way the lock is placed: canonicalise the deepest EXISTING ancestor and append the + * segments below it, case-folded on Windows. Canonicalising an existing ancestor is what folds a + * short name, because that folding belongs to the filesystem rather than to any string rule; a + * tail that does not exist yet can only be folded by the string rule. The walk starts at the + * PARENT, not at the file: the lock is the entry beside the file, so the final component must + * never be resolved through a symlink, or a link and its referent would fold into one key and + * the two distinct .lock entries this call takes on purpose would collapse into one. + */ +async function _lockIdentityKey(absoluteFilePath: string): Promise { + const missing: string[] = [path.basename(absoluteFilePath)] + let current = path.dirname(absoluteFilePath) + for (;;) { + let canonical: string | undefined + try { + canonical = await fs.realpath(current) + } catch { + // This component is not there yet; an ancestor of it may be. + } + if (canonical !== undefined || path.dirname(current) === current) { + const folded = path.join(canonical ?? current, ...missing) + return process.platform === "win32" ? folded.toLowerCase() : folded + } + missing.unshift(path.basename(current)) + current = path.dirname(current) + } +} async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) // One release per lock acquired, kept in acquisition order and released in reverse below. @@ -164,14 +202,16 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // only the link-path lock lets those two writers overlap, and their merge reads overwrite each // other. Resolving the referent here costs one more realpath on a default write; it is a read of // the link target for locking purposes, not a decision to publish through it. - // resolveLockKey canonicalizes and Windows paths are case-insensitive, so the two keys are - // compared the same way the filesystem would: byte-for-byte they can name one file twice, and - // locking a file that this call already locked would stall on its own stale timeout. + // The two keys are compared the way the filesystem would, not the way strings compare: + // byte-for-byte they can name one file twice, and locking a file this call already locked + // would stall on its own stale timeout. Folding is per _lockIdentityKey, because case folding + // alone leaves a short-name spelling unequal to the canonical one resolveLockKey returns. const linkPathLockKey = absoluteFilePath - const sameIdentity = - process.platform === "win32" - ? referentLockKey.toLowerCase() === linkPathLockKey.toLowerCase() - : referentLockKey === linkPathLockKey + const [referentLockIdentity, linkPathLockIdentity] = await Promise.all([ + _lockIdentityKey(referentLockKey), + _lockIdentityKey(linkPathLockKey), + ]) + const sameIdentity = referentLockIdentity === linkPathLockIdentity const lockKeys = publishOverLink ? sameIdentity ? [linkPathLockKey] From 8836103acff977c651c2dffe2591d2266ebdf45d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 14:07:35 +0800 Subject: [PATCH 69/73] fix(safe-write-json): take the lock on the canonical spelling of the link path proper-lockfile writes ${key}.lock, so the spelling of a lock key is part of its identity. The referent key came from resolveLockKey, which canonicalizes the parent directory, while the link-path key was the plain path.resolve result. Under a parent that resolves elsewhere - macOS /var to /private/var under os.tmpdir(), a workspace opened through a symlinked folder, a symlinked home - the two spellings named one file, and a writer that named the canonical path took a lock file in a different directory. One identity, two locks, and the lost update the lock exists to prevent. resolveLinkPathLockKey canonicalizes the parent and keeps the final component unresolved: a link and its referent must keep two distinct keys, which is what lets an unscoped write lock both. Its failure behaviour is canonicalDirKey's, which is also what resolveLockKey already exposes one line earlier in this function - a realpath error that is not ENOENT propagates, and a path whose every ancestor up to the root is missing keeps its literal spelling. The look-alike _resolveScopeRoot was not merged in: it canonicalizes a directory by resolving the path itself, and returns the lexical path at the root, so it would follow the final component this key must not follow. Regression test: a regular file whose parent resolves through a symlink takes exactly one lock, and that lock names the canonical file. Negative control measured in this harness: linkPathLockKey back to the plain path.resolve result -> 1 failed (that test), 38 passed. Also in this commit, review thread 4236123803: a test restored COMPUTERNAME by assignment, which on a host without it writes the literal "undefined" and leaks an invented authority into every later DACL case. It now deletes the variable when it was unset, the way USERDOMAIN already did, and a canary case asserts the rule. Negative control: restoring by assignment again, with COMPUTERNAME absent from the host environment -> 1 failed (the canary), 77 passed. Baselines after the sweep: safeWriteText spec 78 passed; safeWriteJson lock-key and behaviour specs 38 passed / 4 skipped; tsc at this branch's post-merge baseline of 74 error lines with 0 in the touched files; eslint --max-warnings=0 clean on all four files; src/eslint-suppressions.json untouched. One integration case (publishes the new bytes and leaves no staging or backup residue) fails identically before and after this commit: it shells out to a real icacls, which cannot verify a restore in this sandbox. Verified by stashing the change and re-running it at the parent commit. --- .../__tests__/safeWriteText.spec.ts | 34 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 15 ++++++++ .../__tests__/safeWriteJson.lockKey.spec.ts | 34 ++++++++++++++++++- src/utils/safeWriteJson.ts | 3 +- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8dc5291824..8ccc7ee771 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -938,13 +938,45 @@ describe("safeWriteText", () => { try { await expect(safeWriteText(targetPath, "data", { platform: "win32" })).resolves.toBeUndefined() } finally { - process.env.COMPUTERNAME = savedMachine + if (savedMachine === undefined) { + delete process.env.COMPUTERNAME + } else { + process.env.COMPUTERNAME = savedMachine + } if (savedDomain !== undefined) { process.env.USERDOMAIN = savedDomain } } }) + it("win32 DACL: a host without COMPUTERNAME is left without it afterwards", async () => { + // Canary for the case above: it restores the machine name through the rule below, so on a + // host that had no COMPUTERNAME a regression there reaches this test as the string + // "undefined" rather than as an absent variable. + expect(process.env.COMPUTERNAME).not.toBe("undefined") + // Node turns any value assigned to process.env into a string, so restoring an unset + // variable by assignment writes the literal "undefined". Every later case then builds its + // expected principals from an environment this test invented: on a Linux or macOS + // host, where COMPUTERNAME is never set, each one silently accepts undefined\ as + // an authority. USERDOMAIN already restored this way; this is the same rule for the + // machine name, asserted rather than assumed. + const savedMachine = process.env.COMPUTERNAME + delete process.env.COMPUTERNAME + process.env.COMPUTERNAME = "TESTMACHINE" + try { + expect(process.env.COMPUTERNAME).toBe("TESTMACHINE") + } finally { + // The rule the case above now follows, held to on its own: an unset host variable comes + // back unset, never as the string "undefined". + if (savedMachine === undefined) { + delete process.env.COMPUTERNAME + } else { + process.env.COMPUTERNAME = savedMachine + } + expect(process.env.COMPUTERNAME).toBe(savedMachine) + } + }) + 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) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 5376d7cbf6..6a93dc3553 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -414,6 +414,21 @@ export async function resolveLockKey(absoluteFilePath: string): Promise } } +/** + * Lock key for the path itself: canonical parent directory plus basename, with the final + * component never resolved through a link. proper-lockfile writes ${key}.lock, so a key that + * keeps an un-canonicalized parent names one file with a different spelling than the key + * resolveLockKey returns for it - one file, two lock entries on disk, and two writers that + * never serialize. The final component stays unresolved on purpose: a link and its referent + * must keep two distinct keys, which is what lets a caller lock both the alias and the referent. + * Failure behaviour is canonicalDirKey's, which is also what resolveLockKey already exposes one + * line earlier in safeWriteJson: a realpath error that is not ENOENT propagates, and a path + * whose every ancestor up to the root is missing keeps its literal spelling. + */ +export async function resolveLinkPathLockKey(absoluteFilePath: string): Promise { + return await canonicalDirKey(path.resolve(absoluteFilePath)) +} + export async function safeWriteText( filePath: string, content: string | Uint8Array, diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 4e2b7e7038..2e301c3a3f 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -210,7 +210,6 @@ it("does not log a cleanup error when the safety net finds the temp file already await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow("commit rename failed") - // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. // The safety net really ran, and ran on the staged .new_ file it was supposed to remove. // Two unlinks of that path are expected: safeWriteText cleans up the tempPath it was // handed, and then safeWriteJson runs its own safety net on the same path. Dropping the @@ -332,4 +331,37 @@ it("serializes a writer that names the referent directly while the link still ex // referent keeps the content its own writer put there. expect(JSON.parse(await fs.readFile(link, "utf8"))).toEqual({ id: "task-4" }) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ had: "referent content" }) + +}) + +it("takes one lock for a regular file whose parent directory resolves through a symlink", async () => { + // A parent that resolves elsewhere is the ordinary case: macOS /var -> /private/var under + // os.tmpdir(), a workspace opened through a symlinked folder, a symlinked home. The two + // spellings name one file, so a comparison that only folds letter case calls them two + // identities and safeWriteJson acquires both keys - proper-lockfile then creates two .lock + // entries in the same directory, the second acquisition finds the lock this call already + // holds, retries, and fails with ELOCKED on every unscoped write. + const canonicalDir = await makeDir("canonical-parent-") + const aliasDir = path.join(os.tmpdir(), "alias-of-canonical") + const aliasFile = path.join(aliasDir, "cfg.json") + const canonicalFile = path.join(canonicalDir, "cfg.json") + currentLink = "" + // The file is a regular file, not a link: only an ancestor directory resolves elsewhere. + mockedRealpath.mockImplementation(async (target) => { + const t = String(target) + if (t === aliasDir) return canonicalDir + if (t === aliasFile) return canonicalFile + return t + }) + mockedLstat.mockImplementation(async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats) + mockedReadlink.mockRejectedValue(enoent) + + await safeWriteJson(aliasFile, { id: "task-1" }) + + const keys = mockedAcquireFileLock.mock.calls.map((c: unknown[]) => String(c[0])) + expect(keys).toHaveLength(1) + // Which spelling got the lock is the other half of the claim: proper-lockfile writes + // ${key}.lock, so a lock taken on the alias spelling is a different file on disk from the + // one a writer that names the canonical path takes - one identity, two locks again. + expect(keys[0]).toBe(canonicalFile) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index e0321a9358..4880b112ba 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -5,6 +5,7 @@ import { JsonStreamStringify } from "json-stream-stringify" import { acquireFileLock } from "./fileLock" import { + resolveLinkPathLockKey, resolveLockKey, resolvePublishTarget, safeWriteText, @@ -206,7 +207,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // byte-for-byte they can name one file twice, and locking a file this call already locked // would stall on its own stale timeout. Folding is per _lockIdentityKey, because case folding // alone leaves a short-name spelling unequal to the canonical one resolveLockKey returns. - const linkPathLockKey = absoluteFilePath + const linkPathLockKey = await resolveLinkPathLockKey(absoluteFilePath) const [referentLockIdentity, linkPathLockIdentity] = await Promise.all([ _lockIdentityKey(referentLockKey), _lockIdentityKey(linkPathLockKey), From f08604e269207a9a31ca5cd865fb866eb8cdd8c3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 14:25:59 +0800 Subject: [PATCH 70/73] style: reflow the lock-key spec to the repo's prettier output The compile job failed at Check formatting on this head, and the whole warn] list in the job log names exactly one file: src/utils/__tests__/safeWriteJson.lockKey.spec.ts, followed by the log's own 'Code style issues found in the above file'. The formatting gate is the whole list from the log, not an excerpt, so nothing else in this PR's diff needed touching. Formatting only: one mockedLstat.mockImplementation call exceeded printWidth 120 and is wrapped onto its own argument list. No assertion, mock behaviour, or identifier changed; the diff is 3 insertions and 2 deletions in that one file. Done with the repo's own prettier rather than by hand: prettier --write followed by prettier --check with the repo config (printWidth 120, endOfLine lf) reports the file clean. Verified the same way the CI step runs it, from inside the repo, so the local signal matches the gate instead of a default-config run outside it. Verification: the two safeWriteJson specs pass 38 / 4 skipped after the reflow; tsc --noEmit with the local paths override 0 errors; eslint . --ext=ts --max-warnings=0 exits 0; eslint-suppressions.json unchanged. --- src/utils/__tests__/safeWriteJson.lockKey.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 2e301c3a3f..80a266be8d 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -331,7 +331,6 @@ it("serializes a writer that names the referent directly while the link still ex // referent keeps the content its own writer put there. expect(JSON.parse(await fs.readFile(link, "utf8"))).toEqual({ id: "task-4" }) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ had: "referent content" }) - }) it("takes one lock for a regular file whose parent directory resolves through a symlink", async () => { @@ -353,7 +352,9 @@ it("takes one lock for a regular file whose parent directory resolves through a if (t === aliasFile) return canonicalFile return t }) - mockedLstat.mockImplementation(async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats) + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) mockedReadlink.mockRejectedValue(enoent) await safeWriteJson(aliasFile, { id: "task-1" }) From 689d8d0b4c4d0b0ddd4fb6721d579d8ce95f7e50 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 23:58:53 +0800 Subject: [PATCH 71/73] fix(u8): refuse adoption when the rejection was about completeness, and fix the ACL read-back parser canAdoptPublishedContent now takes the write kind. A full-file publish rejected by the completeness gate is not a moved-token rejection, so the autosaved match is no longer adopted as a successful replacement; a targeted edit stays adoptable after a partial read. _aclEntriesAreNarrowedTo strips the echoed file path whole instead of a whitespace token, so a path containing spaces no longer fails every verified narrowing, and the inheritance marker is read from the whole flag run, so an inherited grant to the current user is no longer accepted as verified. The onWarning doc now matches the shipped contract: a capture failure on an existing target rejects with DaclCaptureError before the commit, and the sink carries the access-check warning and the post-commit notices. The createIfAbsent, undoPartialOpen and isCancelled doc blocks move onto the declarations they describe. --- src/core/tools/guardedWrite.ts | 16 +-- src/integrations/editor/DiffViewProvider.ts | 33 ++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 108 ++++++++++++++++++ .../__tests__/safeWriteText.spec.ts | 56 +++++++++ src/services/file-safety/safeWriteText.ts | 48 ++++---- 5 files changed, 220 insertions(+), 41 deletions(-) diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index 13d870308f..d515a1842e 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -127,13 +127,6 @@ async function fileIsAbsent(absolutePath: string): Promise { } } -/** - * 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 @@ -143,6 +136,13 @@ async function tokenAfterPublish(absolutePath: string): Promise undefined) } +/** + * 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. + */ export async function createIfAbsent( absolutePath: string, content: string | Uint8Array, @@ -203,9 +203,9 @@ export async function replaceIfVersion( absolutePath: string, expectedVersion: 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. - displayPath: string, isCancelled?: () => boolean, ): Promise { // Lock the key every other writer to this file uses: the resolved publish diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 7e063be4c2..ab9138e628 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -401,16 +401,6 @@ export class DiffViewProvider { }) } - /** - * Undo the on-disk side effects of an open() that failed before the diff editor was - * created. open() creates the parent directories and writes an empty placeholder - * for a new file; if the diff editor never opens, revertChanges() has no diff - * editor to revert and the unapproved file would stay on disk. The placeholder is - * removed only while it is still the exact file this open() wrote (same version - * token), under the same resolved-path advisory lock every other writer to this - * path uses; the directories this call created then go, innermost first, stopping - * at the first directory another writer populated in the meantime. - */ /** * Whether the path still holds the placeholder this provider created. * The stat-matched token is the strongest proof; when open() could not produce one @@ -433,6 +423,16 @@ export class DiffViewProvider { return stats.dev === identity.dev && stats.ino === identity.ino && stats.size === 0n } + /** + * Undo the on-disk side effects of an open() that failed before the diff editor was + * created. open() creates the parent directories and writes an empty placeholder + * for a new file; if the diff editor never opens, revertChanges() has no diff + * editor to revert and the unapproved file would stay on disk. The placeholder is + * removed only while it is still the exact file this open() wrote (same version + * token), under the same resolved-path advisory lock every other writer to this + * path uses; the directories this call created then go, innermost first, stopping + * at the first directory another writer populated in the meantime. + */ private async undoPartialOpen(absolutePath: string): Promise { if (this.editType !== "create") { return @@ -618,7 +618,7 @@ export class DiffViewProvider { * even though the intended publish is satisfied. That reading is only sound when * the save was authorized against the exact version open() stat-matched. */ - private canAdoptPublishedContent(): boolean { + private canAdoptPublishedContent(writeKind: GuardedWriteKind): boolean { // A create placeholder is the file open() itself wrote, so a match is // unambiguous and the CAS baseline is the placeholder token. if (this.placeholderVersion !== undefined) { @@ -636,6 +636,15 @@ export class DiffViewProvider { if (this.preOpenObservation === null) { return false } + // A full-file publish rejected for a partial read is not a moved-token rejection: + // the completeness gate refused before any compare-and-swap ran, so the matching + // bytes on disk are the un-authorized replacement, not this save's satisfied + // autosave. Adopting them would report a successful full-file replacement for a + // write the completeness gate rejected. A targeted edit is authorized by a + // partial read, so only the full-file kinds take this branch. + if (writeKind !== "edit" && !this.preOpenObservation.complete) { + return false + } // The caller's observation must still name the version the preview saw. If a // different writer moved the file first, the guard was right to reject and the // matching bytes are the clobber it warned about. @@ -724,7 +733,7 @@ export class DiffViewProvider { // only the version token moved. A rejection caused by an external writer, or // by a save that was never authorized at all, must stay a rejection - the // matching bytes are what the clobber looks like from here. - this.canAdoptPublishedContent() && + this.canAdoptPublishedContent(writeKind) && // 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 0dc5394400..ae0f228838 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2438,6 +2438,114 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() }) + it("does not adopt an autosaved match when the completeness gate rejected the update", async () => { + // The model read a slice, so its observation is partial, and open() stat-matched + // that same version - the version gate passes. But the guarded write was refused + // by the completeness gate before any compare-and-swap ran: adopting the + // matching bytes would report a successful full-file replacement for a write the + // completeness gate rejected, with the lines the model never read gone and + // nothing telling the model. + 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), false) + diffViewProvider["preOpenObservation"] = { version: versionTokenOfStat(previewStats), complete: false } + diffViewProvider["openToken"] = versionTokenOfStat(previewStats) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("File was only partially read") + + // The adoption probe never runs once the completeness gate refuses, and the + // observation stays the partial read the model actually made. + expect(fs.readFile).not.toHaveBeenCalled() + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.complete).toBe(false) + expect(observation?.version).toBe(versionTokenOfStat(previewStats)) + }) + + it("adopts an autosaved match when the preview ran against the model's complete observation", async () => { + // The autosave shape with the preview's own pairing: the model read the whole + // file, open() stat-matched that version, and autosave published the accepted + // bytes. Only the version token moved, which is exactly what adoption exists + // for, so the completeness gate must not refuse this one. + 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) + diffViewProvider["preOpenObservation"] = { version: versionTokenOfStat(previewStats), complete: true } + diffViewProvider["openToken"] = versionTokenOfStat(previewStats) + 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) + }) + + it("still adopts an autosaved match for a targeted edit after a partial read", async () => { + // A targeted edit is authorized by a partial read, so the completeness gate + // never rejects it; when autosave published the edit's bytes, the moved-token + // rejection is exactly the adoption shape and must stay adopted. + 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), false) + diffViewProvider["preOpenObservation"] = { version: versionTokenOfStat(previewStats), complete: false } + diffViewProvider["openToken"] = versionTokenOfStat(previewStats) + 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")).resolves.toMatchObject({ + newProblemsMessage: "", + }) + + // The edit's publish keeps the read's completeness: a partial read stays + // partial and cannot authorize a later full-file replacement. + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.complete).toBe(false) + }) + it("does not adopt a match when an external writer moved the file before the preview", async () => { const mockEditor = mockTextEditor(`${mockCwd}/ext-writer.ts`) vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8ccc7ee771..2d1716fa3d 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -884,6 +884,62 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(DaclRestoreError) }) + it("win32 DACL: a narrowing under a file path that contains a space is accepted", async () => { + // icacls echoes the path it was given ahead of the first entry. A workspace path + // with spaces ("OneDrive - Company", "My Projects") is the ordinary case, so the + // echo must be stripped whole: a whitespace-token strip leaves the path's tail in + // the first principal and every publish under such a path fails DaclRestoreError. + const targetPath = "/tmp/test-dir/My Projects/cfg.json" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const warnings: string[] = [] + vi.mocked(execFile).mockImplementation((_cmd, args, _opts, cb) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + callback(null, `${argv[0]} ${os.userInfo().username}:(F)`, "") + } else { + callback(null, "", "") + } + return fakeChild + }) + + await expect( + safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }), + ).resolves.toBeUndefined() + + expect( + warnings.filter((m) => m.includes("narrowed to the current user's full control and verified")), + ).toHaveLength(1) + }) + + it("win32 DACL: an inherited grant to the current user is not accepted as verified", async () => { + // The principal is exactly the expected one, so only the inheritance marker can + // reject this report. The flags of an entry are printed as separate groups + // ("bob:(I)(F)"), so the check must read the whole flag run: a check for the + // literal "(I)" inside a single group's text never fires and calls an inherited + // grant a verified narrowing. + 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) => { + const argv = args as unknown as string[] + const callback = cb as unknown as (err: unknown, stdout: string, stderr: string) => void + if (argv[1] === "/restore") { + callback(new Error("icacls restore error"), "", "") + } else if (argv[1] === undefined) { + callback(null, `${argv[0]} ${os.userInfo().username}:(I)(F)`, "") + } else { + callback(null, "", "") + } + return fakeChild + }) + + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(DaclRestoreError) + }) + it("win32 DACL: a narrowing granted to a same-named account in another domain is not accepted", 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 6a93dc3553..47fbe8bafc 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -29,10 +29,13 @@ 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. + * Sink for non-fatal safety notices: the access-check warning raised when an existing + * target could not be checked before the publish, and the post-commit notices for a + * saved DACL that was narrowed and verified instead of restored, that could not be + * narrowed after a rollback, or whose rollback itself failed and left the pre-write + * content retained. A capture failure on an existing target is not a notice: it + * rejects the write with DaclCaptureError before the commit rename, so the target + * keeps its previous content under its previous ACL. Defaults to console.warn. */ onWarning?: (message: string) => void @@ -244,27 +247,30 @@ function _expectedAcePrincipals(identity: string): string[] { } return expected } -function _aclEntriesAreNarrowedTo(report: string, identity: string): boolean { - // Every "principal:(flags)" pair in the report is an entry; the file path that icacls prints - // ahead of the first entry is stripped below so it cannot satisfy the check by containing the - // user's name. - const pattern = /([^:()]+):\(([^()]*)\)/g +function _aclEntriesAreNarrowedTo(report: string, filePath: string, identity: string): boolean { const expectedPrincipals = _expectedAcePrincipals(identity) + // icacls echoes the path it was given ahead of the first entry. Strip that known prefix + // whole, so a path containing spaces ("C:\\Users\\My Projects\\...") cannot leak into the + // first principal the way a whitespace-token strip would leak it. + let body = report.trimStart() + if (body.toLowerCase().startsWith(filePath.toLowerCase())) { + body = body.slice(filePath.length) + } let count = 0 - for (const match of report.matchAll(pattern)) { - let principal = match[1].trim() - // The first entry shares its line with the path. Only a leading token that looks like a - // path is dropped: a principal such as "NT AUTHORITY\\SYSTEM" legitimately contains a space - // and must survive intact. - const parts = principal.split(/\s+/) - if (parts.length > 1 && /^[A-Za-z]:[\\/]|^[\\/]/.test(parts[0])) { - principal = parts.slice(1).join(" ").trim() + for (const rawLine of body.split(/\r?\n/)) { + // One entry per line: ":(flags)(flags)...". Summary lines + // ("Successfully processed ...") and blanks carry no entry and are skipped. + const match = /^(.+?):((?:\([^()]*\))+)$/.exec(rawLine.trim()) + if (!match) { + continue } - const flags = match[2] - if (flags.includes("(I)")) { + // The flags group is the whole run of parenthesized groups, so the inheritance + // marker is matched as it is printed - "bob:(I)(F)" included. A verified + // narrowing holds only explicit grants: any inherited entry rejects the report. + if (match[2].includes("(I)")) { return false } - if (!expectedPrincipals.includes(principal.toLowerCase())) { + if (!expectedPrincipals.includes(match[1].trim().toLowerCase())) { return false } count++ @@ -306,7 +312,7 @@ async function _restrictDaclWindows( // so a substring match would pass on a file that grants that user nothing. Every entry must be // an explicit grant to the current user with no inherited component, which is the only state // this helper claims to have verified. - return _aclEntriesAreNarrowedTo(readBack, identity) + return _aclEntriesAreNarrowedTo(readBack, filePath, identity) } // -- public API ------------------------------------------------------------ From d15c8494a9b96b5f54c9eebf924d27303384227c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 15:22:17 +0800 Subject: [PATCH 72/73] fix(editor): reconcile the observation when a guarded publish lands without durability DiffViewProvider.saveDirectly() calls guardedWrite() directly, so it never passes through the saveChanges() catch. When safeWriteText() threw PostCommitDurabilityError the commit rename had already placed the bytes at the target, but the guard returned no published token and the task observation stayed on the pre-write version, so the next guarded write from that task was rejected stale against the version it had published itself. Reconcile at the shared publication boundary rather than in an editor-only branch, so every caller inherits the contract: - createIfAbsent() and replaceIfVersion() record the token a landed-but-not-durable publish wrote, reading it under the same lock they published under so a peer writer's token is never adopted; - guardedWrite() refreshes the observation from that token and rethrows the original PostCommitDurabilityError, so a durability failure is still not reported as a successful save; - the completeness rule is now one expression read by both the success refresh and the reconciliation, so a partial observation that authorized a targeted edit stays partial either way. Tests pin the landed publish on both guard paths, the preserved partial observation, the unobserved create, the untouched observation for a failure that stopped before the commit point, the skipped refresh when the post-publish stat fails, and one saveDirectly() case at the call site the finding named. Refs #1375 --- src/core/tools/__tests__/guardedWrite.spec.ts | 125 ++++++++++++- src/core/tools/guardedWrite.ts | 177 ++++++++++++------ .../editor/__tests__/DiffViewProvider.spec.ts | 49 ++++- 3 files changed, 292 insertions(+), 59 deletions(-) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts index e111bd74db..bf8f533053 100644 --- a/src/core/tools/__tests__/guardedWrite.spec.ts +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -15,7 +15,7 @@ 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 { PostCommitDurabilityError, safeWriteText } from "../../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../../utils/versionToken" import { withFileLock } from "../../../utils/fileLock" import { resolveLockKey } from "../../../services/file-safety/safeWriteText" @@ -36,6 +36,18 @@ vi.mock("../../../utils/versionToken", () => ({ vi.mock("../../../services/file-safety/safeWriteText", () => ({ safeWriteText: vi.fn(), resolveLockKey: vi.fn(async (p: string) => p), + // The guard only needs the error's identity to recognise a publish whose commit + // rename landed, and this spec replaces the whole module anyway, so the class is + // restated here with the real constructor shape rather than imported. + PostCommitDurabilityError: class PostCommitDurabilityError extends Error { + readonly targetPath: string + + constructor(targetPath: string, cause: unknown) { + super("the rename committed but the parent directory could not be fsynced", { cause }) + this.name = "PostCommitDurabilityError" + this.targetPath = targetPath + } + }, })) vi.mock("../../../utils/fileLock", () => ({ @@ -444,6 +456,117 @@ describe("guardedWrite (S4a, epic #1375)", () => { }) }) + // The commit rename is the commit point: when it lands and only the + // parent-directory fsync after it fails, the bytes are already at the + // target. The observation has to follow the disk, or the next guarded write + // from this task is rejected stale against the version it published itself - + // and DiffViewProvider.saveDirectly() reaches guardedWrite with no + // reconciliation of its own to absorb that. The failure still has to reach + // the caller: a durability check that did not pass is not a successful save. + describe("post-commit durability failure", () => { + it("reconciles the observation to the landed token and still rejects with the durability error", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + const landed = new PostCommitDurabilityError(abs("a.txt"), new Error("EIO: i/o error")) + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // the compare-and-swap read + .mockResolvedValueOnce("v2") // the post-publish read, under the same lock + mockedSafeWriteText.mockRejectedValueOnce(landed) + + await expect(guardedWrite(task, "a.txt", "next", "update")).rejects.toBe(landed) + + expect(reg.get(abs("a.txt"))?.version).toBe("v2") + // A full-file publish is complete: the model supplied the whole content. + expect(reg.get(abs("a.txt"))?.complete).toBe(true) + }) + + it("lets the next guarded write from the same task publish instead of failing stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + const landed = new PostCommitDurabilityError(abs("a.txt"), new Error("EIO: i/o error")) + mockedComputeVersionToken.mockResolvedValueOnce("v1").mockResolvedValueOnce("v2") + mockedSafeWriteText.mockRejectedValueOnce(landed) + + await expect(guardedWrite(task, "a.txt", "next", "update")).rejects.toBe(landed) + + // The disk now carries this write's bytes, so the follow-up write must be + // accepted against the landed token rather than rejected with the stale + // remediation it would otherwise get against "v1". + mockedComputeVersionToken.mockResolvedValue("v2") + + await guardedWrite(task, "a.txt", "final", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("a.txt"))?.version).toBe("v2") + }) + + it("keeps a partial observation partial when an edit landed without durability", async () => { + // The reconciliation must not upgrade completeness: an edit is authorized + // by the view the model saw, and a later full-file replacement still has to + // be rejected until the whole file is read. + const reg = new ObservationRegistry() + reg.observe(abs("slice.txt"), "v1", false) + const task = createMockTask({ observationRegistry: reg }) + const landed = new PostCommitDurabilityError(abs("slice.txt"), new Error("EIO: i/o error")) + mockedComputeVersionToken.mockResolvedValueOnce("v1").mockResolvedValueOnce("v2") + mockedSafeWriteText.mockRejectedValueOnce(landed) + + await expect(guardedWrite(task, "slice.txt", "patched", "edit")).rejects.toBe(landed) + + expect(reg.get(abs("slice.txt"))?.version).toBe("v2") + expect(reg.get(abs("slice.txt"))?.complete).toBe(false) + }) + + it("records an unobserved create that landed without durability as a complete observation", async () => { + const reg = new ObservationRegistry() + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask({ observationRegistry: reg }) + const landed = new PostCommitDurabilityError(abs("new.txt"), new Error("EIO: i/o error")) + mockedComputeVersionToken.mockResolvedValueOnce("v9") + mockedSafeWriteText.mockRejectedValueOnce(landed) + + await expect(guardedWrite(task, "new.txt", "hello", "create")).rejects.toBe(landed) + + expect(reg.get(abs("new.txt"))?.version).toBe("v9") + expect(reg.get(abs("new.txt"))?.complete).toBe(true) + }) + + it("leaves the observation untouched when the publish failed before the commit point", async () => { + // Only a landed rename reconciles. Any other publish failure means the + // target still holds the pre-write content, so the observation must keep + // the token that describes it. + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The CAS read matches; any later token read would name a different version, so a + // reconciliation that fired here would be visible instead of coincidentally equal. + mockedComputeVersionToken.mockResolvedValueOnce("v1").mockResolvedValue("v2") + mockedSafeWriteText.mockRejectedValueOnce(new Error("EACCES: permission denied")) + + await expect(guardedWrite(task, "a.txt", "next", "update")).rejects.toThrow("EACCES: permission denied") + + expect(reg.get(abs("a.txt"))?.version).toBe("v1") + }) + + it("keeps the previous observation when the landed token cannot be read", async () => { + // Same rule as the success path: a failed stat skips the reconciliation, + // it does not change the error the caller receives. + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + const landed = new PostCommitDurabilityError(abs("a.txt"), new Error("EIO: i/o error")) + mockedComputeVersionToken + .mockResolvedValueOnce("v1") + .mockRejectedValueOnce(Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })) + mockedSafeWriteText.mockRejectedValueOnce(landed) + + await expect(guardedWrite(task, "a.txt", "next", "update")).rejects.toBe(landed) + + expect(reg.get(abs("a.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() diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts index d515a1842e..50934e141a 100644 --- a/src/core/tools/guardedWrite.ts +++ b/src/core/tools/guardedWrite.ts @@ -24,7 +24,7 @@ import * as fs from "fs/promises" import * as path from "path" -import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { PostCommitDurabilityError, safeWriteText } from "../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../utils/versionToken" import { withFileLock } from "../../utils/fileLock" import { resolveLockKey } from "../../services/file-safety/safeWriteText" @@ -136,6 +136,36 @@ async function tokenAfterPublish(absolutePath: string): Promise undefined) } +/** + * The token a publish landed with when its commit rename succeeded but the + * durability check after it failed. Keyed by the error the guard rethrows, so + * the reconciliation in guardedWrite can follow the disk without adopting a + * peer writer's token: the guard reads it under the same lock it published + * under, and an error that is not this write's simply has no entry. + */ +const landedPublishTokens = new WeakMap() + +/** + * Called by a guard when its publish rejects. A PostCommitDurabilityError means + * the rename already placed the bytes at the target, so record the token that + * landed publish wrote before the error leaves the lock. A failed stat only + * skips the recording: it does not change the error the caller receives, and it + * must not turn a landed publish into one that is forgotten. + */ +async function recordLandedPublish(absolutePath: string, error: unknown): Promise { + if (error instanceof PostCommitDurabilityError) { + const token = await tokenAfterPublish(absolutePath) + if (token !== undefined) { + landedPublishTokens.set(error, token) + } + } +} + +/** The token a landed-but-not-durable publish wrote, or undefined for any other failure. */ +function landedPublishToken(error: unknown): string | undefined { + return error instanceof PostCommitDurabilityError ? landedPublishTokens.get(error) : undefined +} + /** * Publish content only if the target file does not exist. * @@ -164,7 +194,14 @@ export async function createIfAbsent( } // Immediately before publication starts. cancelledBeforePublish(absolutePath, displayPath, isCancelled) - await safeWriteText(absolutePath, content) + try { + await safeWriteText(absolutePath, content) + } catch (error: unknown) { + // A rename that landed before the durability check failed still placed the + // bytes at the target: hand the token up before the error leaves the lock. + await recordLandedPublish(absolutePath, error) + throw error + } // 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. @@ -240,7 +277,14 @@ export async function replaceIfVersion( if (currentVersion === expectedVersion) { // Immediately before publication starts. cancelledBeforePublish(absolutePath, displayPath, isCancelled) - await safeWriteText(absolutePath, content) + try { + await safeWriteText(absolutePath, content) + } catch (error: unknown) { + // A rename that landed before the durability check failed still placed the + // bytes at the target: hand the token up before the error leaves the lock. + await recordLandedPublish(absolutePath, error) + throw error + } // 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. @@ -332,66 +376,85 @@ export async function guardedWrite( ) } 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 + // Completeness the observation is refreshed with. A targeted edit authorizes only + // the view the model saw, so a partial observation stays partial, while a full-file + // publish is complete unless the caller carried another view's completeness through + // in completeOverride. One rule, read by the success refresh and by the + // reconciliation below, so the two can never disagree about what one publish records. + const completeAfterPublish = (): boolean => completeOverride ?? !(obs?.complete === 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) + try { + 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, + ) + } } 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, - ) - } + // "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, - ) + 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, + ) + } } + } catch (error: unknown) { + // A commit rename that landed but failed the durability check after it has + // already put this write's bytes at the target, and the guard rethrows that + // failure with the token it read under the same lock as the publish. The + // observation has to follow the disk: left on the pre-write token, the next + // guarded write from this task is rejected stale against the version it just + // published itself, and the save path that calls guardedWrite directly + // (DiffViewProvider.saveDirectly) has no reconciliation of its own to absorb + // that. The error is rethrown untouched -- a durability failure is not a + // successful save, and nothing here reports one. + const landedToken = landedPublishToken(error) + if (landedToken !== undefined) { + task.observationRegistry.observe(absolutePath, landedToken, completeAfterPublish()) + } + throw error } // A publish changes the on-disk token (the rename changes ino, size, and @@ -405,7 +468,7 @@ export async function guardedWrite( // 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) + task.observationRegistry.observe(absolutePath, publishedToken, completeAfterPublish()) } }) } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index ae0f228838..349dd338f8 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -10,7 +10,7 @@ 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 { PostCommitDurabilityError, safeWriteText } from "../../../services/file-safety/safeWriteText" import { withFileLock } from "../../../utils/fileLock" import { createDirectoriesForFile } from "../../../utils/fs" import { ObservationRegistry } from "../../../core/task/observationRegistry" @@ -39,6 +39,18 @@ vi.mock("fs/promises", () => ({ vi.mock("../../../services/file-safety/safeWriteText", () => ({ safeWriteText: vi.fn().mockResolvedValue(undefined), resolveLockKey: vi.fn(async (p: string) => p), + // The guarded write recognises a landed-but-not-durable publish by this error's + // identity, and this spec replaces the whole module, so the class is restated + // here with the real constructor shape rather than imported. + PostCommitDurabilityError: class PostCommitDurabilityError extends Error { + readonly targetPath: string + + constructor(targetPath: string, cause: unknown) { + super("the rename committed but the parent directory could not be fsynced", { cause }) + this.name = "PostCommitDurabilityError" + this.targetPath = targetPath + } + }, })) // Mock the S1 version token (used by the S4 guarded write); the real @@ -1182,6 +1194,41 @@ describe("DiffViewProvider", () => { ) expect(safeWriteText).not.toHaveBeenCalled() }) + + it("reconciles the observation when a direct save landed but was not durable", async () => { + // saveDirectly calls guardedWrite directly, so it has no catch of its own to + // reconcile with: the commit rename put the bytes at the target and only the + // parent-directory fsync after it failed. Left on "v1", the next save from + // this task would be rejected stale against the version this save published + // itself. + const landed = new PostCommitDurabilityError(`${mockCwd}/test.ts`, new Error("EIO")) + // v1 for the compare-and-swap, v2 for the post-publish read under the same + // lock, then v2 again for the follow-up save. The queue is handed back in a + // finally: this spec clears but does not reset mocks between tests, so a + // half-consumed once-queue would leak into the tests that follow. + vi.mocked(computeVersionToken) + .mockResolvedValueOnce("v1") + .mockResolvedValueOnce("v2") + .mockResolvedValueOnce("v2") + .mockResolvedValueOnce("v2") + vi.mocked(safeWriteText).mockRejectedValueOnce(landed) + + try { + const save = diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0) + + await expect(save).rejects.toBe(landed) + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe("v2") + + // The follow-up save is accepted: the disk already carries the bytes + // this save published, so the guard must not call them a stale version. + const followUp = diffViewProvider.saveDirectly("test.ts", "later content", false, false, 0) + + await expect(followUp).resolves.toMatchObject({ finalContent: "later content" }) + expect(safeWriteText).toHaveBeenCalledTimes(2) + } finally { + vi.mocked(computeVersionToken).mockReset().mockResolvedValue("v1") + } + }) }) }) From 41c97d84220c4deba7a29e5219c7dc96ae7f2e88 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 15:24:33 +0800 Subject: [PATCH 73/73] fix(utils): decide confinement once and reject a declared-empty root safeWriteJson read the confinement option twice with two different tests: the link publication decision compared it against undefined while the two scope checks tested it for truthiness. An empty string therefore declared a scope on one line and left the write unconfined on the other - the publish went through the symlink referent with no scope check behind it. Derive confinement presence once, with an explicit undefined comparison, before the lock key is resolved, and have the link decision, both scope checks, the publish target, the merge read and the publish options read that single value. A declared root that is empty or whitespace-only cannot contain any canonicalized path - path.resolve("") names the process working directory, not a scope anybody named - so it now fails closed with a ConfinedPathEscapeError naming the declared root, before the lock key, the lock, directory creation or staging. Re-derived for this branch rather than copied byte for byte: this unit carries no ancestor pin, so the readers that obey the single value here are the link decision, the two scope checks, the publish target, the merge read and the publish options. Tests pin both directions of the decision - an absent option stays unconfined, an empty option is confined - the whitespace-only root, and the ordering against the lock-key seam. Refs #1375 --- src/utils/__tests__/safeWriteJson.test.ts | 93 +++++++++++++++++++++++ src/utils/safeWriteJson.ts | 59 ++++++++++++-- 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index fd99f0fdbe..f140436a79 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -973,4 +973,97 @@ describe("safeWriteJson", () => { expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) await expect(fs.readFile(path.join(other, "mcp.json"), "utf8")).rejects.toThrow() }) + + // The confinement option is read once, from one decision, and every reader obeys + // it. The shape this replaces decided the link publication with + // `confineTo === undefined` while the two scope checks tested the same option for + // truthiness, so an empty string was a declared scope on one line and no scope at + // all on the next: the publish went through the symlink referent with no scope + // check behind it. Both directions are pinned, because either half can regress + // alone. + test("treats an absent confineTo as unconfined and an empty confineTo as confined", async () => { + const projectDir = path.join(tempDir, "declared-or-not") + await fs.mkdir(projectDir) + const inside = path.join(projectDir, "settings.json") + + // Absent: no scope declared, unconfined, and the write lands. + await safeWriteJson(inside, { first: true }) + expect(await readFileContent(inside)).toEqual({ first: true }) + + // Declared - even as an empty string: a scope exists, so it has to be checked. + const error = await safeWriteJson(inside, { second: true }, { confineTo: "" }).then( + () => undefined, + (reason: unknown) => reason, + ) + + expect(error).toBeInstanceOf(ConfinedPathEscapeError) + if (error instanceof ConfinedPathEscapeError) { + // The rejection names the root that was declared. Substituting + // path.resolve("") would confine the write to the process working directory + // - a scope nobody declared. + expect(error.confineTo).toBe("") + } + // The rejected write did not proceed. + expect(await readFileContent(inside)).toEqual({ first: true }) + }) + + test("rejects a whitespace-only confineTo and names the declared root", async () => { + // path.resolve(" ") is a directory named " " under the process cwd, so a + // whitespace-only root is no more a scope than an empty one: it fails closed + // with the declared root, the same decision as the empty string. + const error = await safeWriteJson(currentTestFilePath, { mcpServers: {} }, { confineTo: " " }).then( + () => undefined, + (reason: unknown) => reason, + ) + + expect(error).toBeInstanceOf(ConfinedPathEscapeError) + if (error instanceof ConfinedPathEscapeError) { + expect(error.confineTo).toBe(" ") + } + expect(await readFileContent(currentTestFilePath)).toEqual({ initial: "content" }) + }) + + test("rejects a declared-empty confineTo before the lock key, the lock, the parent directory or any staging", async () => { + vi.resetModules() + // The target's parent does not exist, so an mkdir would create it outside the + // declared scope, and proper-lockfile would create ${lockKey}.lock beside the + // lock key. The realpath spy is the lock-key seam: resolveLockKey canonicalizes + // through it, so a rejection that still names the declared root proves the + // decision ran before the key was computed rather than after it. + const projectDir = path.join(tempDir, "declared-empty-order") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "declared-empty-missing-parent", "nested.json") + + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn(async () => { + throw new Error("lock taken for a declared-empty scope (test)") + }) + vi.doMock("proper-lockfile", () => ({ ...realLockfile, lock: lockMockFn })) + const { safeWriteJson: lockedSafeWriteJson } = await import("../safeWriteJson") + const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (candidate) => { + throw Object.assign(new Error(`ENOTSUP: the lock key was resolved (test): ${candidate}`), { + code: "ENOTSUP", + }) + }) + + try { + const reason = await lockedSafeWriteJson(outside, { mcpServers: {} }, { confineTo: "" }).then( + () => undefined, + (caught: unknown) => caught, + ) + // The module was re-imported, so its error class is a different identity than + // the one this spec imported: recognised by name and payload instead. + const failure = reason as ConfinedPathEscapeError | undefined + expect(failure?.name).toBe("ConfinedPathEscapeError") + expect(failure?.confineTo).toBe("") + expect(lockMockFn).not.toHaveBeenCalled() + const entries = await fs.readdir(tempDir) + expect(entries).not.toContain("declared-empty-missing-parent") + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + } finally { + realpathSpy.mockRestore() + vi.doUnmock("proper-lockfile") + vi.resetModules() + } + }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 4880b112ba..bd256953c2 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -40,6 +40,11 @@ export interface SafeWriteJsonOptions { * 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. + * + * Confinement is active whenever this option is DEFINED, not when it is truthy: + * an empty or whitespace-only root is a declared scope that cannot contain any + * canonicalized path, so it is rejected with a {@link ConfinedPathEscapeError} + * naming the declared root rather than read as "no scope was declared". */ confineTo?: string } @@ -120,6 +125,30 @@ function _scopeErrorCode(error: unknown): string | undefined { : undefined } +/** + * The scope root a caller declared, or undefined when no scope was declared. + * + * This is the ONE place confinement presence is decided, and it decides it with an + * explicit undefined comparison rather than a truthiness test: an empty string is a + * declared scope, and a reader that tested truthiness would call the same write + * unconfined while another reader called it confined. + * + * A declared root that is empty or whitespace-only cannot contain any canonicalized + * path - path.resolve("") names the process working directory and path.resolve(" ") + * names a directory nobody named - so it fails closed here with the declared root on + * the error, instead of silently disabling the checks below or confining the write to + * a directory the caller never named. + */ +function _declaredScopeRoot(confineTo: string | undefined, requestedPath: string): string | undefined { + if (confineTo === undefined) { + return undefined + } + if (confineTo.trim() === "") { + throw new ConfinedPathEscapeError(requestedPath, requestedPath, confineTo) + } + return confineTo +} + /** * Safely writes JSON data to a file. * - Creates parent directories if they don't exist @@ -194,7 +223,22 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // confinement scope publishes through the referent and therefore locks the referent. Locking // the referent while replacing the link lets two writers hold two different locks for one // publish target - the lost update the lock exists to prevent. - const publishOverLink = options?.confineTo === undefined + // + // Confinement presence is decided ONCE, here, with an explicit undefined comparison, + // and every reader below - the link decision, both scope checks, the publish target, + // the merge read and the publish options - obeys this one value. Deciding it twice, + // once as an undefined comparison and once as a truthiness test, let confineTo: "" + // skip both scope checks while the link decision still published through the + // referent: confined in shape, confining nothing in fact. A declared root that is + // empty or whitespace-only cannot contain any canonicalized path, so it fails closed + // here - before the lock key is resolved, before the lock is taken, before any + // directory is created and before anything is staged. + const confinementRoot = _declaredScopeRoot(options?.confineTo, absoluteFilePath) + const confinementDeclared = confinementRoot !== undefined + // A default (unconfined) write replaces the link itself, so the publish must not + // re-resolve the path it is handed; a confined write publishes through the referent + // the two scope checks below clear. + const publishOverLink = !confinementDeclared const referentLockKey = await resolveLockKey(absoluteFilePath) // A confined caller publishes through the referent, so the referent is the identity it replaces // and the only lock it needs. A default write replaces the link itself, so the link path is the @@ -229,8 +273,8 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // 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) + if (confinementRoot !== undefined) { + const scopeRoot = await _resolveScopeRoot(confinementRoot) _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(referentLockKey), scopeRoot) } @@ -275,15 +319,16 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // redirect a default write (McpHub passes no scope) onto a file nobody authorized, which is // the boundary the confined callers exist to protect. A default write therefore replaces the // link itself, as this primitive did before it resolved links at all. - resolvedTargetPath = options?.confineTo ? await resolvePublishTarget(absoluteFilePath) : absoluteFilePath + // Same decision as publishOverLink, read from the same value. + resolvedTargetPath = publishOverLink ? absoluteFilePath : 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) + if (confinementRoot !== undefined) { + const scopeRoot = await _resolveScopeRoot(confinementRoot) _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(resolvedTargetPath), scopeRoot) } @@ -297,7 +342,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // copy JSON from outside the requested path into a file the caller named. The merge sees // no existing document, exactly as it would if the path did not exist. let mergeTargetIsLink = false - if (!options?.confineTo) { + if (publishOverLink) { try { mergeTargetIsLink = (await fs.lstat(resolvedTargetPath)).isSymbolicLink() } catch (linkError: unknown) {