From d5f8a79c75e74be66078ec8a9884a056ccc628bf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 20:34:31 +0800 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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 cf86a42d6057d9c6c9078f584a08a862e81a4cf3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 02:48:40 +0800 Subject: [PATCH 07/38] fix(file-safety): inherit unit 1 committed guard and exact rmdir assertion 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, and its spec asserted rmdir generically rather than against the staging directory this write created. Both are already settled in unit 1 (fws/u1-atomic-publish). Taking those files 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 a587a078595bfcd48676269ef0cfb158d16b5a67 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:04:44 +0800 Subject: [PATCH 08/38] test: rerun windows lane - local core suite is green (1073 tests), the failure did not reproduce From f7de7540c2f0ceb54096424970f21565bee40702 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:44:10 +0800 Subject: [PATCH 09/38] 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 4ea379b6942ccf9f20744f8266cbd8c97603bf57 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 03:45:34 +0800 Subject: [PATCH 10/38] 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 8ba6fcadab88d3f1048668068268f8d7fd1f91c6 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 05:21:34 +0800 Subject: [PATCH 11/38] test: re-trigger required checks - the queued runs were cancelled by the Actions queue, no source change From 2945ab23f4ce035c5640092196c97526e707eed2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 06:47:12 +0800 Subject: [PATCH 12/38] 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 8c8db375e18594f7bdbf4d207a5a79f155f31bfa Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:53:10 +0800 Subject: [PATCH 13/38] fix(file-safety): keep the published target across a post-commit durability error Port of the U3 commit-state tracking fix to this unit's copies (same PI row family, identical catch shape): - safeWriteJson clears the staged path when safeWriteText reports PostCommitDurabilityError. The rename landed, so the staged path is the published target's former name; the catch cleanup must not unlink a path this write no longer owns (a concurrent writer reusing the staging name would lose its temp file). The durability error rethrows, so the write is never observed as a confirmed-durable success. - safeWriteText's failure cleanup only removes the staged temp when the commit rename never landed (the committed flag it already tracks); after the commit the staged path names the published target. Regression test: the parent-directory fsync fails after the commit rename; safeWriteJson rejects with PostCommitDurabilityError, the target stays present with the new JSON, and no unlink fires at the consumed staging path. Negative controls: removing either guard reddens exactly that test. 80/81 with the file-safety suites (1 pre-existing skip). --- src/services/file-safety/safeWriteText.ts | 14 +++++-- src/utils/__tests__/safeWriteJson.test.ts | 45 +++++++++++++++++++++-- src/utils/safeWriteJson.ts | 11 ++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e548e5c3b5..4326ea2c3a 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -477,10 +477,16 @@ export async function safeWriteText( rollbackFailure = { error: rollbackError, backupPath } } } - try { - await fs.unlink(tempPath).catch(() => {}) - } catch { - // cleanup failure is non-fatal + // Only a write that never committed has a staged file to remove. After the + // commit rename the staged path names the published target: unlinking it + // here would at best miss with ENOENT, and at worst delete a file a + // concurrent writer has already claimed under the same staging name. + if (!committed) { + try { + await fs.unlink(tempPath).catch(() => {}) + } catch { + // cleanup failure is non-fatal + } } // A failed self-staged write must not leave its staging directory behind. diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 245af61910..451bfd3a82 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -4,7 +4,7 @@ import * as path from "path" import * as os from "os" import { safeWriteJson } from "../safeWriteJson" -import { RollbackFailureError } from "../../services/file-safety/safeWriteText" +import { PostCommitDurabilityError, RollbackFailureError } from "../../services/file-safety/safeWriteText" import * as lockfile from "proper-lockfile" // Capture actual implementations before the vi.mock factory runs, @@ -441,7 +441,7 @@ describe("safeWriteJson", () => { }) // 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 () => { + test("throws RollbackFailureError with the publish failure as cause when the rollback rename also fails, leaving an orphaned backup", async () => { const initialData = { message: "Initial, orphaned when rollback fails" } const newData = { message: "New content" } @@ -463,7 +463,6 @@ describe("safeWriteJson", () => { 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 @@ -702,4 +701,44 @@ describe("safeWriteJson", () => { expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) }, ) + + // ── post-commit durability: the published target must survive (PI regression) ── + + it("keeps the published target with its new content when the post-commit directory fsync fails", async () => { + const fsSyncMod = await import("fs") + const realOpen = fsSyncMod.openSync + const realFsync = fsSyncMod.fsyncSync + // A sentinel fd identifies the parent-directory open that safeWriteText + // performs after the commit rename; only that fsync fails. + const sentinel = 987654 + vi.spyOn(process, "platform", "get").mockReturnValue("linux") + vi.spyOn(fsSyncMod, "openSync").mockImplementation(((...args: unknown[]) => { + if (String(args[0]) === tempDir) { + return sentinel + } + return (realOpen as (...a: unknown[]) => unknown)(...args) + }) as never) + vi.spyOn(fsSyncMod, "fsyncSync").mockImplementation(((fd: number) => { + if (fd === sentinel) { + throw new Error("EIO fsync") + } + return (realFsync as (f: number) => unknown)(fd) + }) as never) + + // Earlier tests in this file leave unlink history on the shared mock. + vi.mocked(fs.unlink).mockClear() + + await expect(safeWriteJson(currentTestFilePath, { fresh: "content" })).rejects.toBeInstanceOf( + PostCommitDurabilityError, + ) + + // The rename committed: the target must still hold the new JSON, not be + // unlinked away by the catch cleanup of the consumed staging path. + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual({ fresh: "content" }) + // The cleanup must not unlink the consumed staging path: after the commit + // rename it is no longer this write's file to remove. + const stagingUnlinks = vi.mocked(fs.unlink).mock.calls.filter((call) => /\.new_.*\.tmp$/.test(String(call[0]))) + expect(stagingUnlinks).toHaveLength(0) + }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index bae200dd38..999f563091 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 { + PostCommitDurabilityError, resolveLockKey, resolvePublishTarget, safeWriteText, @@ -139,6 +140,16 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso originalError, ) + // PostCommitDurabilityError means the commit rename landed: the staged + // path was renamed onto the target, so it is no longer a leftover temp + // file. Unlinking it here would at best race a concurrent writer that has + // already reused the staging name, and the published target must survive + // the durability failure with its new content. The error rethrows below, + // so the write is never observed as a confirmed-durable success. + if (originalError instanceof PostCommitDurabilityError) { + actualTempNewFilePath = null + } + const newFileToCleanupWithinCatch = actualTempNewFilePath // A failed safeWriteText already rolled the backup (if any) back to From 628fcafa9b6a0921b0ae62c55262fecd34d64ef7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:20:05 +0800 Subject: [PATCH 14/38] style(file-safety): drop imports unused after the main refresh (lint gate) --- src/services/file-safety/__tests__/safeWriteText.spec.ts | 1 - src/utils/__tests__/safeWriteJson.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8b76906dda..ee8627a310 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -10,7 +10,6 @@ import { RollbackFailureError, safeWriteText, StagingPathError, - type SafeWriteTextOptions, } from "../safeWriteText" // Full mock for fs/promises — all methods are vi.fn() stubs diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 7a59427d78..2847ab0319 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -5,7 +5,6 @@ import * as os from "os" import { safeWriteJson } from "../safeWriteJson" import { PostCommitDurabilityError, 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 From 75dd374f03be86fc9692b06b514abea51fec14c0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:42:36 +0800 Subject: [PATCH 15/38] style(file-safety): reformat specs for the prettier version main now pins (format gate) --- .../file-safety/__tests__/safeWriteText.spec.ts | 4 +++- .../__tests__/safeWriteJson.lockKey.spec.ts | 17 ++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index ee8627a310..df2484ee47 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -359,7 +359,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, so the backup must // not be renamed back over it: only target->backup and temp->target run. diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 33989f8b81..8e20682a50 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -50,12 +50,13 @@ afterEach(async () => { // 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 +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", () => { @@ -152,7 +153,9 @@ describe("safeWriteJson lock key under a peer commit", () => { if (target === file) throw enoent return canonicalDir }) - mockedLstat.mockImplementation(async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats) + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) }) From 6d0bc87422ad93a9c808c1e7bfb98df8e16a64cc Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:00:42 +0800 Subject: [PATCH 16/38] test(file-safety): pin the durability wrapper's cause and the exact staging release Port of the U3 review follow-ups to this unit's copies: the injected fsync failure targets a real directory descriptor and the test asserts the PostCommitDurabilityError carries it as its cause; the staging-release assertions compare the exact directory path read back from the create instead of a substring. --- .../__tests__/safeWriteText.spec.ts | 19 ++++++++++----- src/utils/__tests__/safeWriteJson.test.ts | 24 ++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index df2484ee47..bd47ba29e2 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -143,11 +143,14 @@ describe("safeWriteText", () => { 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) + // the staging subdir is removed best-effort after the commit rename. + // Exact path read back from the create: the SUT releases the same + // directory it staged in, and a substring match would also accept a + // release aimed at some other staging directory. expect(fs.rmdir).toHaveBeenCalledTimes(1) - expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + const stagingDir = String(vi.mocked(fsSync.mkdirSync).mock.calls[0]?.[0]) + expect(stagingDir).toContain(".file-safety-staging") + expect(fs.rmdir).toHaveBeenCalledWith(stagingDir) // the win32 DACL restore gate must stay closed on other platforms: // no icacls save or restore is attempted expect(execFile).not.toHaveBeenCalled() @@ -163,7 +166,9 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "hello")).resolves.toBeUndefined() expect(fs.rmdir).toHaveBeenCalledTimes(1) - expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + const stagingDir = String(vi.mocked(fsSync.mkdirSync).mock.calls[0]?.[0]) + expect(stagingDir).toContain(".file-safety-staging") + expect(fs.rmdir).toHaveBeenCalledWith(stagingDir) if (process.platform === "win32") { // default platform is win32: the DACL save + restore still ran // through the default icacls path (options?.execFileRunner must @@ -197,7 +202,9 @@ describe("safeWriteText", () => { // 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")) + const stagingDir = String(vi.mocked(fsSync.mkdirSync).mock.calls[0]?.[0]) + expect(stagingDir).toContain(".file-safety-staging") + expect(fs.rmdir).toHaveBeenCalledWith(stagingDir) }) it("gives each self-staged write its own staging directory so a concurrent write cannot remove it", async () => { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 2847ab0319..7fd8c670b1 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -707,19 +707,23 @@ describe("safeWriteJson", () => { const fsSyncMod = await import("fs") const realOpen = fsSyncMod.openSync const realFsync = fsSyncMod.fsyncSync - // A sentinel fd identifies the parent-directory open that safeWriteText - // performs after the commit rename; only that fsync fails. - const sentinel = 987654 + // The parent-directory open that safeWriteText performs after the commit + // rename gets a REAL descriptor: safeWriteText closes it in its finally + // block, and a fabricated number would make closeSync fail with EBADF - + // a second failure behind the same wrapper, hiding which fsync broke. + const fsyncFailure = new Error("EIO fsync") + let dirFd: number | undefined vi.spyOn(process, "platform", "get").mockReturnValue("linux") vi.spyOn(fsSyncMod, "openSync").mockImplementation(((...args: unknown[]) => { if (String(args[0]) === tempDir) { - return sentinel + dirFd = (realOpen as (p: string, f: string) => number)(tempDir, "r") + return dirFd } return (realOpen as (...a: unknown[]) => unknown)(...args) }) as never) vi.spyOn(fsSyncMod, "fsyncSync").mockImplementation(((fd: number) => { - if (fd === sentinel) { - throw new Error("EIO fsync") + if (fd === dirFd) { + throw fsyncFailure } return (realFsync as (f: number) => unknown)(fd) }) as never) @@ -727,9 +731,13 @@ describe("safeWriteJson", () => { // Earlier tests in this file leave unlink history on the shared mock. vi.mocked(fs.unlink).mockClear() - await expect(safeWriteJson(currentTestFilePath, { fresh: "content" })).rejects.toBeInstanceOf( - PostCommitDurabilityError, + const failure: unknown = await safeWriteJson(currentTestFilePath, { fresh: "content" }).then( + () => null, + (error: unknown) => error, ) + expect(failure).toBeInstanceOf(PostCommitDurabilityError) + // The wrapper must carry the injected fsync failure, not a cleanup error. + expect((failure as Error).cause).toBe(fsyncFailure) // The rename committed: the target must still hold the new JSON, not be // unlinked away by the catch cleanup of the consumed staging path. From 4fa16f236e5abdc47224dd9d61b9fa30f60a8bbb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:09:27 +0800 Subject: [PATCH 17/38] test(file-safety): keep the durability probe platform-portable Opening a directory with openSync(path, "r") is not portable: on the Windows CI runner it fails, and the failed open - not the injected fsync - became the wrapped cause, reddening test:misc there. The spy now uses a real descriptor where the platform allows it and falls back to a sentinel with a stubbed close where it does not, so the injected EIO fsync is the only error in flight and the cause assertion holds on every platform (verified by forcing the fallback path locally). --- src/utils/__tests__/safeWriteJson.test.ts | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 7fd8c670b1..e796006013 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -708,19 +708,36 @@ describe("safeWriteJson", () => { const realOpen = fsSyncMod.openSync const realFsync = fsSyncMod.fsyncSync // The parent-directory open that safeWriteText performs after the commit - // rename gets a REAL descriptor: safeWriteText closes it in its finally - // block, and a fabricated number would make closeSync fail with EBADF - - // a second failure behind the same wrapper, hiding which fsync broke. + // rename gets a REAL descriptor where the platform allows it: the SUT + // closes the fd in its finally block, and closing a fabricated number + // would raise EBADF there - a second failure behind the same wrapper, + // hiding which fsync broke. Where opening a directory is not allowed + // (Windows), a sentinel stands in and the close is stubbed for it, so + // the injected fsync failure is the only error in flight on every + // platform. const fsyncFailure = new Error("EIO fsync") + const realClose = fsSyncMod.closeSync let dirFd: number | undefined + let dirFdIsReal = false vi.spyOn(process, "platform", "get").mockReturnValue("linux") vi.spyOn(fsSyncMod, "openSync").mockImplementation(((...args: unknown[]) => { if (String(args[0]) === tempDir) { - dirFd = (realOpen as (p: string, f: string) => number)(tempDir, "r") + try { + dirFd = (realOpen as (p: string, f: string) => number)(tempDir, "r") + dirFdIsReal = true + } catch { + dirFd = 987654 + } return dirFd } return (realOpen as (...a: unknown[]) => unknown)(...args) }) as never) + vi.spyOn(fsSyncMod, "closeSync").mockImplementation(((fd: number) => { + if (fd === dirFd && !dirFdIsReal) { + return undefined + } + return (realClose as (f: number) => unknown)(fd) + }) as never) vi.spyOn(fsSyncMod, "fsyncSync").mockImplementation(((fd: number) => { if (fd === dirFd) { throw fsyncFailure From d5a68d13f3d1b462dc8f48e8ce800b059bfd5f82 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:29:40 +0800 Subject: [PATCH 18/38] test(file-safety): inject the durability failure without fabricating a descriptor The Windows CI lane rejects the fabricated-descriptor probe: the openSync/closeSync spy pair around the parent-directory handle behaved differently on the runner than on any local configuration (node 22 and 25, forks and threads, one to many workers, CI=true, contention). The probe now watches the commit rename itself - the parent-directory fsync is the only fsync that runs after it - so the SUT opens and closes its own real handle, no descriptor is invented, and the injected EIO failure is the only error in flight on every platform. The cause assertion and the staging-release assertions are unchanged; removing the injection reddens exactly the durability test. --- src/utils/__tests__/safeWriteJson.test.ts | 43 +++++++---------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index e796006013..8c08b8e9ac 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -705,41 +705,22 @@ describe("safeWriteJson", () => { it("keeps the published target with its new content when the post-commit directory fsync fails", async () => { const fsSyncMod = await import("fs") - const realOpen = fsSyncMod.openSync const realFsync = fsSyncMod.fsyncSync - // The parent-directory open that safeWriteText performs after the commit - // rename gets a REAL descriptor where the platform allows it: the SUT - // closes the fd in its finally block, and closing a fabricated number - // would raise EBADF there - a second failure behind the same wrapper, - // hiding which fsync broke. Where opening a directory is not allowed - // (Windows), a sentinel stands in and the close is stubbed for it, so - // the injected fsync failure is the only error in flight on every - // platform. + // The parent-directory fsync is the only fsync that runs after the + // commit rename. Watching the rename itself - rather than fabricating a + // descriptor - keeps the SUT in charge of opening and closing its own + // real directory handle, so the injected fsync failure is the only + // error in flight on every platform. const fsyncFailure = new Error("EIO fsync") - const realClose = fsSyncMod.closeSync - let dirFd: number | undefined - let dirFdIsReal = false + let committed = false + const actualRename = fsPromisesActuals.rename! + vi.mocked(fs.rename).mockImplementation((async (from: string, to: string) => { + await actualRename(from, to) + committed = true + }) as typeof fs.rename) vi.spyOn(process, "platform", "get").mockReturnValue("linux") - vi.spyOn(fsSyncMod, "openSync").mockImplementation(((...args: unknown[]) => { - if (String(args[0]) === tempDir) { - try { - dirFd = (realOpen as (p: string, f: string) => number)(tempDir, "r") - dirFdIsReal = true - } catch { - dirFd = 987654 - } - return dirFd - } - return (realOpen as (...a: unknown[]) => unknown)(...args) - }) as never) - vi.spyOn(fsSyncMod, "closeSync").mockImplementation(((fd: number) => { - if (fd === dirFd && !dirFdIsReal) { - return undefined - } - return (realClose as (f: number) => unknown)(fd) - }) as never) vi.spyOn(fsSyncMod, "fsyncSync").mockImplementation(((fd: number) => { - if (fd === dirFd) { + if (committed) { throw fsyncFailure } return (realFsync as (f: number) => unknown)(fd) From 5f938837bb3da7337822aea139f2bc69806bf536 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:40:59 +0800 Subject: [PATCH 19/38] fix(file-safety): fall back to the link walk only on a dangling link resolveLockKey caught every resolution error and fell back to the bounded readlink walk, so a transient EACCES or EIO from realpath or lstat keyed the write to the alias path while a writer reaching the referent directly keyed the referent - two locks for one file, and the alias/referent serialization the unit promises did not hold. Only ENOENT (the dangling-link case the walk exists for) now falls through; every other error propagates, matching resolvePublishTarget's own policy. Regression test: EACCES from realpath rejects resolveLockKey instead of returning an alias key; removing the guard reddens exactly that test. Also restore the rename/unlink/console spies in a finally block in the cleanup-safety test: a failed assertion previously left the spies active for the afterEach rm and every later test. --- src/services/file-safety/safeWriteText.ts | 9 ++++- .../__tests__/safeWriteJson.lockKey.spec.ts | 38 +++++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 4326ea2c3a..3a607a73ba 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -218,7 +218,14 @@ async function canonicalDirKey(absoluteFilePath: string): Promise { export async function resolveLockKey(absoluteFilePath: string): Promise { try { return await canonicalDirKey(await resolvePublishTarget(absoluteFilePath)) - } catch { + } catch (error: unknown) { + // Only a dangling link (ENOENT) may fall through to the walk: any other + // resolution error (EACCES, EIO) would key this writer to the alias path + // while a writer reaching the referent directly keys the referent, and + // the two writers would then take different locks for one file. + if (errorCode(error) !== "ENOENT") { + throw error + } // 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. diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 8e20682a50..89fb3af12c 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -159,6 +159,23 @@ describe("safeWriteJson lock key under a peer commit", () => { expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) }) + + it("propagates a non-ENOENT resolution failure instead of keying on the alias", async () => { + // EACCES from realpath is not a dangling link: falling back to the walk + // would key this writer to the alias path while a writer reaching the + // referent directly keys the referent, and the two writers would take + // different locks for the same file. + const aliasDir = path.join(os.tmpdir(), "alias-dir") + const file = path.join(aliasDir, "history_item.json") + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + mockedRealpath.mockImplementation(async () => { + throw eacces + }) + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) + await expect(resolveLockKey(file)).rejects.toBe(eacces) + }) }) it("does not log a cleanup error when the safety net finds the temp file already gone", async () => { @@ -175,12 +192,17 @@ it("does not log a cleanup error when the safety net finds the temp file already 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() + try { + await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow("commit rename failed") + + // Only the original failure is reported. + expect(consoleError).toHaveBeenCalledTimes(1) + } finally { + // Restore even when an assertion fails: the afterEach cleanup runs fs.rm + // with these spies still in place otherwise, and later tests inherit a + // rejecting rename. + renameSpy.mockRestore() + unlinkSpy.mockRestore() + consoleError.mockRestore() + } }) From e20939c034b773a99317b9f368dce198ce84951f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:17:02 +0800 Subject: [PATCH 20/38] test(safety): drop the dead lstat mock in the EACCES lock-key test resolvePublishTarget rethrows the realpath error before lstat runs, so the mock could never affect the error-identity assertion (CodeRabbit thread). --- src/utils/__tests__/safeWriteJson.lockKey.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 89fb3af12c..025aa898d6 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -171,9 +171,6 @@ describe("safeWriteJson lock key under a peer commit", () => { mockedRealpath.mockImplementation(async () => { throw eacces }) - mockedLstat.mockImplementation( - async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, - ) await expect(resolveLockKey(file)).rejects.toBe(eacces) }) }) From 1190af8a57a282062163330ad4b90ba8820ba3c4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:59:31 +0800 Subject: [PATCH 21/38] fix(safety): port copy-based backup and harden caller-staged tempPath Adopts the lineage's evolved safeWriteText (the #1912 copy): the backup is now copied without ever moving the target, closing the crash-loss window between a backup rename and the commit rename (Persistence Integrity row), and a caller-staged tempPath must carry the .new_*.tmp staging convention and still be a single-named regular file when opened, so neither an arbitrary file beside the target nor a hard-link substitution between the check and the open can be published onto the target (Security Boundaries row). Specs are ported with the implementation; the confineTo coverage stays with #1912 (U3 scope). --- .../__tests__/safeWriteText.spec.ts | 843 ++++++++++++++++-- src/services/file-safety/safeWriteText.ts | 488 ++++++++-- .../__tests__/safeWriteJson.lockKey.spec.ts | 19 +- src/utils/__tests__/safeWriteJson.test.ts | 73 +- 4 files changed, 1189 insertions(+), 234 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index bd47ba29e2..f02bb8af2a 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -5,15 +5,17 @@ import type { ChildProcess } from "child_process" import * as path from "path" import { + OrphanedBackupError, PostCommitDurabilityError, resolveLockKey, - RollbackFailureError, safeWriteText, StagingPathError, } from "../safeWriteText" // 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(), @@ -34,6 +36,7 @@ vi.mock("fs", () => ({ fsyncSync: vi.fn(), chmodSync: vi.fn(), fchmodSync: vi.fn(), + fstatSync: vi.fn(), statSync: vi.fn(), Stats: class Stats {}, })) @@ -62,6 +65,28 @@ function _fileStats(isLink: boolean): fsSync.Stats { const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats s.isSymbolicLink = () => isLink s.isFile = () => !isLink + // Single name by default: the post-open verification rejects a staged file that + // has gained a second (hard-link) name. + s.nlink = 1 + return s +} +// Directory stand-in: neither a symlink nor a regular file. The symlink double short-circuits +// on isSymbolicLink(), so only this double reaches the branch that rejects any other file type. +function _notARegularFileStats(): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats + s.isSymbolicLink = () => false + s.isFile = () => false + 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 } @@ -78,6 +103,8 @@ function mockDefaults(): void { // 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)) + // Post-open default: the opened file is the same single-named regular file. + vi.mocked(fsSync.fstatSync).mockReturnValue(_fileStats(false)) } function _stats(mode: number): fsSync.Stats { const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats @@ -180,7 +207,7 @@ describe("safeWriteText", () => { 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" + const callerTemp = "/tmp/test-dir/.new_caller-staged.tmp" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -191,20 +218,75 @@ describe("safeWriteText", () => { expect(fs.rmdir).not.toHaveBeenCalled() }) - it("a failed staging-dir removal never fails the committed write", async () => { + it("retries a failed staging-dir removal, never fails the committed write, and reports the exact path", async () => { const targetPath = "/tmp/test-dir/target.txt" + const warnings: string[] = [] 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() + await expect( + safeWriteText(targetPath, "hello", { platform: "linux", onWarning: (m) => warnings.push(m) }), + ).resolves.toBeUndefined() - // the commit rename still happened and the rmdir error was swallowed + // the commit rename still happened, and the removal was retried once expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(fs.rmdir).toHaveBeenCalledTimes(2) + const stagingDirs = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0])) + expect(stagingDirs.length).toBe(1) + expect(fs.rmdir).toHaveBeenNthCalledWith(1, stagingDirs[0]) + expect(fs.rmdir).toHaveBeenNthCalledWith(2, stagingDirs[0]) + // the leftover is not swallowed: the exact directory path reaches the warning sink + const reported = warnings.filter((m) => m.includes(stagingDirs[0])) + expect(reported).toHaveLength(1) + expect(reported[0]).toContain("could not remove the staging directory") + }) + + it("treats a staging directory that someone else already removed as a completed cleanup", async () => { + // ENOENT from the rmdir is the goal, not a leftover: reporting it would send the reader + // looking for a directory that is already gone. + const targetPath = "/tmp/test-dir/target.txt" + const warnings: string[] = [] + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rmdir).mockRejectedValue( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + + await expect( + safeWriteText(targetPath, "hello", { platform: "linux", onWarning: (m) => warnings.push(m) }), + ).resolves.toBeUndefined() + expect(fs.rmdir).toHaveBeenCalledTimes(1) - const stagingDir = String(vi.mocked(fsSync.mkdirSync).mock.calls[0]?.[0]) - expect(stagingDir).toContain(".file-safety-staging") - expect(fs.rmdir).toHaveBeenCalledWith(stagingDir) + // The removal that reported ENOENT has to be the one aimed at this write's own + // staging directory: a cleanup that removed some other directory and got + // ENOENT is not this contract, and it would leave this directory behind. + const stagingDirs = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0])) + expect(stagingDirs.length).toBe(1) + expect(fs.rmdir).toHaveBeenCalledWith(stagingDirs[0]) + expect(warnings).toEqual([]) + }) + + it("reports a staging directory it could not remove after a failed write, without replacing the write error", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const warnings: string[] = [] + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The commit rename fails, and the post-failure cleanup then fails twice. + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + vi.mocked(fs.rmdir).mockRejectedValue(Object.assign(new Error("EPERM"), { code: "EPERM" })) + + await expect( + safeWriteText(targetPath, "data", { platform: "linux", onWarning: (m) => warnings.push(m) }), + ).rejects.toThrow("ENOSPC") + + const stagingDirs = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0])) + expect(stagingDirs.length).toBe(1) + expect(fs.rmdir).toHaveBeenCalledTimes(2) + expect(fs.rmdir).toHaveBeenNthCalledWith(2, stagingDirs[0]) + const reported = warnings.filter((m) => m.includes(stagingDirs[0])) + expect(reported).toHaveLength(1) + expect(reported[0]).toContain("could not remove the staging directory") }) it("gives each self-staged write its own staging directory so a concurrent write cannot remove it", async () => { @@ -259,7 +341,7 @@ describe("safeWriteText", () => { 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" + const callerTemp = "/tmp/test-dir/.new_caller-staged.tmp" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) @@ -275,6 +357,36 @@ 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", () => { @@ -341,15 +453,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_")) @@ -370,18 +480,62 @@ describe("safeWriteText", () => { 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_")) + }) + + it("does not let the failure cleanup unlink the file the commit already published", async () => { + // A tiny filesystem so that "the target is still there" is a STATE assertion: a cleanup + // that deleted the published file and a later step that recreated it would both satisfy + // a weaker "the content is at the target" check. + const targetPath = "/tmp/test-dir/target.txt" + const dirPath = path.dirname(targetPath) + const files = new Set([targetPath]) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.rename).mockImplementation(async (from: unknown, to: unknown) => { + files.delete(String(from)) + files.add(String(to)) + }) + vi.mocked(fs.unlink).mockImplementation(async (p: unknown) => { + files.delete(String(p)) + }) + // The post-commit parent-directory fsync is the failure under test. + 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, + ) + + expect(files.has(targetPath)).toBe(true) + // The staged path is the committed file from the rename onwards: the cleanup must not + // reach for it at all. Before the commit point was tracked, every post-commit + // failure (directory fsync, DACL restore, a throwing warning sink) ran the temp + // unlink against the name the target now lives under. + const stagedPath = String(vi.mocked(fs.rename).mock.calls[0][0]) + expect( + vi.mocked(fs.unlink).mock.calls.filter(function (call) { + return String(call[0]) === stagedPath + }), + ).toHaveLength(0) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) }) }) - // ── 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) @@ -391,68 +545,139 @@ 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("carries the leftover path when the partial backup cannot be removed either", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + 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") + } + }) + let unlinkAttempts = 0 + vi.mocked(fs.unlink).mockImplementation(async (p: unknown) => { + if (String(p).includes("safeWriteText.bak_")) { + unlinkAttempts++ + } + throw Object.assign(new Error("EPERM"), { code: "EPERM" }) + }) + + // The copy failed and the locked leftover could not be unlinked even after the + // retry. Dropping the path would leave a partial copy of the previous content on + // disk with no reference to it anywhere, so the error carries it. + const error = await safeWriteText(targetPath, "new data", { + backup: true, + platform: "linux", + }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(OrphanedBackupError) + const orphan = error as OrphanedBackupError + expect(orphan.orphanedBackupPath).toContain("safeWriteText.bak_") + expect(orphan.message).toContain("safeWriteText.bak_") + expect(orphan.message).toContain("EPERM") + expect(orphan.originalError).toBeInstanceOf(Error) + expect((orphan.originalError as Error).message).toBe("EIO") + expect(orphan.cause).toBe(orphan.originalError) + expect(unlinkAttempts).toBe(2) + expect(fs.rename).not.toHaveBeenCalled() }) it("backup:true when target does not exist: no backup created, just commit", async () => { @@ -478,6 +703,240 @@ describe("safeWriteText", () => { }) }) + it("retries a failed post-commit backup cleanup once and reports the leftover path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const onWarning = vi.fn() + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + // Step 6 removes the backup copy after the commit; the unlink keeps failing. + vi.mocked(fs.unlink).mockRejectedValue(new Error("EPERM")) + + await safeWriteText(targetPath, "data", { backup: true, platform: "linux", onWarning }) + + // The publish succeeded, so the write still resolves - but the leftover copy of the + // previous content must be retried once and then reported with its path, never dropped. + const backupPath = vi.mocked(fs.copyFile).mock.calls[0][1] + const backupUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call) { + return String(call[0]).includes("safeWriteText.bak") + }) + expect(backupUnlinks.length).toBe(2) + expect( + backupUnlinks.map(function (call) { + return call[0] + }), + ).toEqual([backupPath, backupPath]) + expect(onWarning).toHaveBeenCalledTimes(1) + expect(String(onWarning.mock.calls[0][0])).toContain(String(backupPath)) + }) + + it("reports the exact orphan paths when cleanup fails after a failed write, keeping the write error", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const onWarning = vi.fn() + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + // The commit rename fails, and every cleanup unlink fails as well. + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + vi.mocked(fs.unlink).mockRejectedValue(new Error("EACCES")) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux", onWarning })).rejects.toThrow( + "ENOSPC", + ) + + const backupPath = vi.mocked(fs.copyFile).mock.calls[0][1] + const tempArg = vi.mocked(fs.rename).mock.calls[0][0] + // Both leftovers are retried once, and each leftover is reported with its exact path + // instead of being dropped - the caller still gets the original write error. + expect( + vi.mocked(fs.unlink).mock.calls.filter(function (call) { + return String(call[0]) === String(backupPath) + }).length, + ).toBe(2) + expect( + vi.mocked(fs.unlink).mock.calls.filter(function (call) { + return String(call[0]) === String(tempArg) + }).length, + ).toBe(2) + expect(onWarning).toHaveBeenCalledTimes(2) + const messages = onWarning.mock.calls.map(function (call) { + return String(call[0]) + }) + expect( + messages.some(function (m) { + return m.includes(String(backupPath)) + }), + ).toBe(true) + expect( + messages.some(function (m) { + return m.includes(String(tempArg)) + }), + ).toBe(true) + }) + + it("treats an already-gone staging temp as a completed cleanup, not as a leftover", async () => { + // ENOENT from the temp unlink means the goal is already met. Retrying it and then + // warning claimed an orphan that did not exist - a false alarm that trains the reader + // to ignore the leftover reports that matter. + const targetPath = "/tmp/test-dir/target.txt" + const onWarning = vi.fn() + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + vi.mocked(fs.unlink).mockRejectedValue( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + + await expect(safeWriteText(targetPath, "data", { platform: "linux", onWarning })).rejects.toThrow("ENOSPC") + + // ENOENT short-circuits: the goal is met, so there is nothing to retry and + // nothing to report. + expect(fs.unlink).toHaveBeenCalledTimes(1) + expect(onWarning).not.toHaveBeenCalled() + }) + + it("still reports a staging temp that could not be removed for any other reason", async () => { + // The tolerance is scoped to ENOENT. EACCES means the staged file is still on disk, and + // swallowing it would turn a false alarm into a false silence. + const targetPath = "/tmp/test-dir/target.txt" + const onWarning = vi.fn() + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + vi.mocked(fs.unlink).mockRejectedValue( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }), + ) + + await expect(safeWriteText(targetPath, "data", { platform: "linux", onWarning })).rejects.toThrow("ENOSPC") + + const tempArg = vi.mocked(fs.rename).mock.calls[0][0] + expect(onWarning).toHaveBeenCalledTimes(1) + expect(String(onWarning.mock.calls[0][0])).toContain("could not remove the staging temp") + expect(String(onWarning.mock.calls[0][0])).toContain(String(tempArg)) + expect(String(onWarning.mock.calls[0][0])).toContain("EACCES") + }) + + it("treats an already-gone backup copy as removed instead of reporting a phantom orphan", async () => { + // Same rule on the backup copy: ENOENT means there is nothing left to recover by hand, + // so the warning would point at a file that is not there. + const targetPath = "/tmp/test-dir/target.txt" + const onWarning = vi.fn() + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.unlink).mockImplementation(async (p: unknown) => { + if (String(p).includes("safeWriteText.bak_")) { + throw enoent + } + }) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux", onWarning })).rejects.toThrow( + "ENOSPC", + ) + + const backupPath = String(vi.mocked(fs.copyFile).mock.calls[0][1]) + expect( + onWarning.mock.calls.filter(function (call) { + return String(call[0]).includes(backupPath) + }), + ).toHaveLength(0) + }) + + it("retries a failed DACL-dump cleanup once and reports the leftover dump path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const onWarning = vi.fn() + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + // The dump unlink keeps failing. The published file is fine, but the dump is a text copy + // of the target's access rights that only this write knew the name of. + vi.mocked(fs.unlink).mockRejectedValue(new Error("EPERM")) + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning }) + + const dumpUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call) { + return String(call[0]).includes("safeWriteText.acl") + }) + expect(dumpUnlinks.length).toBe(2) + expect( + dumpUnlinks.map(function (call) { + return call[0] + }), + ).toEqual([dumpUnlinks[0][0], dumpUnlinks[0][0]]) + // Reported once, with the exact path, and the write still resolved. + const messages = onWarning.mock.calls.map(function (call) { + return String(call[0]) + }) + const dumpReports = messages.filter(function (m) { + return m.includes("could not remove the DACL dump") + }) + expect(dumpReports.length).toBe(1) + expect(dumpReports[0]).toContain(String(dumpUnlinks[0][0])) + }) + + it("reports the DACL dump path after a failed write without replacing the write error", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const onWarning = vi.fn() + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + vi.mocked(fs.unlink).mockRejectedValue(new Error("EPERM")) + + await expect(safeWriteText(targetPath, "data", { platform: "win32", onWarning })).rejects.toThrow("ENOSPC") + + const dumpUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call) { + return String(call[0]).includes("safeWriteText.acl") + }) + // The commit span owns the dump removal, so the failure path must not retry the same file + // and report the same leftover twice. + expect(dumpUnlinks.length).toBe(2) + const messages = onWarning.mock.calls.map(function (call) { + return String(call[0]) + }) + expect( + messages.filter(function (m) { + return m.includes("could not remove the DACL dump") + }).length, + ).toBe(1) + expect( + messages.some(function (m) { + return m.includes("safeWriteText.acl") + }), + ).toBe(true) + }) + + it("backup:true closes the seed descriptor even when the copy fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + // Distinct descriptors, and the backup's own id recorded, so the assertion below cannot be + // satisfied by the close of some other descriptor. + let nextFd = 40 + const backupFds: number[] = [] + vi.mocked(fsSync.openSync).mockImplementation(function (p) { + const id = nextFd++ + if (String(p).includes("safeWriteText.bak")) backupFds.push(id) + return id + }) + // The copy is the statement that sits between the open and the close. + vi.mocked(fs.copyFile).mockRejectedValue(new Error("EIO: copy failed")) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow("EIO") + + expect(backupFds.length).toBe(1) + // Every successful openSync gets a close attempt, including this path, because an open + // descriptor holds the backup file and blocks the cleanup below. + expect(fsSync.closeSync).toHaveBeenCalledWith(backupFds[0]) + // And the half-made backup does not outlive the attempt. + expect( + vi.mocked(fs.unlink).mock.calls.some(function (call) { + return String(call[0]).includes("safeWriteText.bak") + }), + ).toBe(true) + }) + // ── Test 5: win32 DACL path ────────────────────────────────────────────── describe("win32 DACL", () => { @@ -522,6 +981,65 @@ 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) + }) + + // 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) @@ -574,32 +1092,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 @@ -613,10 +1133,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")) }) @@ -640,6 +1165,46 @@ describe("safeWriteText", () => { // no dump file created or unlinked expect(fs.unlink).not.toHaveBeenCalled() }) + + it("reports a partial DACL dump that the failed capture could not remove", async () => { + // A failed icacls /save can leave a partial dump behind. Dropping the path with a + // catch(() => {}) meant a transient EPERM - antivirus, a handle still being released - + // left a concrete file beside the target whose name nothing could recover. + const targetPath = "/tmp/test-dir/target.txt" + const onWarning = vi.fn() + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The capture itself fails, so nothing may be restored onto the committed file. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") { + cb(new Error("icacls save error"), "", "") + } + return fakeChild + }) + vi.mocked(fs.unlink).mockRejectedValue( + Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }), + ) + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning }) + + // The write still proceeds: a failing icacls must not leave the user unable to save. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + // The partial dump is retried and then reported with its exact path. + const dumpUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call) { + return String(call[0]).includes("safeWriteText.acl") + }) + expect(dumpUnlinks).toHaveLength(2) + expect( + onWarning.mock.calls.map(function (call) { + return String(call[0]) + }), + ).toEqual( + expect.arrayContaining([ + expect.stringContaining("could not remove the DACL dump"), + expect.stringContaining("safeWriteText.acl"), + ]), + ) + }) }) // ── Test 6: pre-written temp path (tempPath option) ────────────────────── @@ -650,7 +1215,7 @@ describe("safeWriteText", () => { vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - const customTempPath = "/tmp/test-dir/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/.new_custom-temp.tmp" // platform:linux skips DACL entirely so this test focuses on tempPath only await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -677,7 +1242,7 @@ describe("safeWriteText", () => { vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) vi.mocked(fsSync.openSync).mockReturnValue(2) - const customTempPath = "/tmp/test-dir/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/.new_custom-temp.tmp" await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -697,7 +1262,7 @@ describe("safeWriteText", () => { }) vi.mocked(fsSync.openSync).mockReturnValue(2) - const customTempPath = "/tmp/test-dir/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/.new_custom-temp.tmp" await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -715,7 +1280,7 @@ describe("safeWriteText", () => { }) vi.mocked(fsSync.openSync).mockReturnValue(2) - const customTempPath = "/tmp/test-dir/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/.new_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. @@ -747,7 +1312,7 @@ describe("safeWriteText", () => { vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o444)) vi.mocked(fsSync.openSync).mockReturnValue(3) - const customTempPath = "/tmp/test-dir/custom-temp.tmp" + const customTempPath = "/tmp/test-dir/.new_custom-temp.tmp" await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) @@ -1075,35 +1640,135 @@ describe("caller-supplied staging path", () => { expect(fsSync.openSync).not.toHaveBeenCalled() expect(fs.rename).not.toHaveBeenCalled() }) + + it("rejects a staging path that is a directory rather than a regular file", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const supplied = "/tmp/test-dir/staging-dir" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // The symlink double above short-circuits on isSymbolicLink(), so the "another file + // type" branch is only reachable through a path that is neither link nor file. A + // directory would otherwise be renamed over the target, publishing a directory in + // place of the file. + vi.mocked(fs.lstat).mockResolvedValue(_notARegularFileStats()) + + await expect(safeWriteText(targetPath, "data", { tempPath: supplied, platform: "linux" })).rejects.toThrow( + "Staging file must be a regular file, not another file type", + ) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).not.toHaveBeenCalled() + // The rejection names the path the caller has to clean up. + await expect( + safeWriteText(targetPath, "data", { tempPath: supplied, platform: "linux" }), + ).rejects.toMatchObject({ stagingPath: path.resolve(supplied) }) + }) + + 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 = _fileStatsWithIdentity(42n, 7n) + 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() + // 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() + }) + + it("rejects a caller-staged file that does not carry the staging name convention", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // An arbitrary regular file beside the target passes the location, file-type and + // target-identity checks, yet renaming it onto the target would publish an + // unrelated file's content and delete its original name. Only the staging name + // convention this library's own writers use may be published. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/credentials.json", platform: "linux" }), + ).rejects.toThrow("Staging file must carry the staging name convention") + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("rejects a caller-staged file that gained a second hard link before the open", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // Between the lstat check and the open, an attacker who can write the directory + // can replace the staging path with a hard link to another file in it: the + // opened inode then carries a second name, and the commit rename would publish + // that other file and delete its original name. + const linked = _fileStats(false) + linked.nlink = 2 + vi.mocked(fsSync.fstatSync).mockReturnValue(linked) + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/.new_x_1.tmp", platform: "linux" }), + ).rejects.toThrow("Staging file changed between the check and the open") + expect(fs.rename).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 = _fileStatsWithIdentity(42n, 7n) + 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() + // 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 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 3a607a73ba..270e16f8dc 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 @@ -25,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 @@ -34,29 +44,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 @@ -91,6 +78,34 @@ export class PostCommitDurabilityError extends Error { this.targetPath = targetPath } } + +/** + * The backup copy could not be created AND the partial copy could not be removed. + * The write failed either way, but the leftover is a copy of the previous content that + * is still on disk: its path travels on the error so the caller can remove it, instead + * of the cleanup silently discarding the only reference to it. + */ +export class OrphanedBackupError extends Error { + readonly orphanedBackupPath: string + readonly originalError: unknown + readonly cleanupError: unknown + constructor(backupPath: string, targetPath: string, cause: unknown, cleanupError: unknown) { + super(_orphanedBackupMessage(targetPath, backupPath, cause, cleanupError), { cause }) + this.name = "OrphanedBackupError" + this.orphanedBackupPath = backupPath + this.originalError = cause + this.cleanupError = cleanupError + } +} + +function _orphanedBackupMessage(targetPath: string, backupPath: string, cause: unknown, cleanupError: unknown): string { + const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error)) + return ( + `safeWriteText: could not create the backup of ${targetPath} (${reason(cause)}), and the ` + + `partial copy at ${backupPath} could not be removed (${reason(cleanupError)}). The copy is ` + + `still on disk and must be deleted.` + ) +} // -- helpers --------------------------------------------------------------- /** Generate a unique temp file name in the given directory. */ @@ -118,6 +133,66 @@ function _stagingDir(dir: string): string { return sd } +/** Remove this write's own staging directory, retrying once and reporting the exact path + * when it still fails. Shared by the success and the failure path so neither one silently + * discards a cleanup failure: an ENOTEMPTY from a racing writer, an EPERM while a handle + * inside the directory is still being released, or a transient filesystem error leaves a + * concrete directory on disk that no caller can find again. A cleanup failure must never + * un-commit a published file and must never replace the original write error, so it travels + * through the warning sink carrying the path a later cleanup pass needs. */ +async function _releaseStagingDir(stagingDir: string, warn: (message: string) => void): Promise { + let cleanupError: unknown = null + // Retry once: Windows reports EPERM while a handle inside the directory is still being + // released, and the second attempt usually succeeds. + for (let attempt = 0; attempt < 2; attempt++) { + try { + await fs.rmdir(stagingDir) + return + } catch (error: unknown) { + // A directory someone else already removed is exactly the goal, not a + // leftover to report. + if (errorCode(error) === "ENOENT") { + return + } + cleanupError = error + } + } + warn( + `safeWriteText: could not remove the staging directory ${stagingDir} (${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }); it is left in place for a later cleanup pass`, + ) +} + +/** + * Remove this write's DACL dump. Retried once for the same reason the backup copy is: + * on Windows an unlink commonly reports EPERM while another handle to the file is still + * being released, although the file is gone a moment later. A dump that survives both + * attempts is a concrete file beside the target whose name no caller can recover, so the + * exact path is reported through the warning sink instead of being swallowed - on the + * success path as well as on the failure path, where it must also never replace the + * original write error. ENOENT means the goal is already met, so it is not a failure. + */ +async function _discardDaclDump(daclDumpPath: string, warn: (message: string) => void): Promise { + let cleanupError: unknown = null + for (let attempt = 0; attempt < 2; attempt++) { + try { + await fs.unlink(daclDumpPath) + return + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + return + } + cleanupError = error + } + } + warn( + `safeWriteText: could not remove the DACL dump ${daclDumpPath} (${ + errorCode(cleanupError) ?? (cleanupError instanceof Error ? cleanupError.message : String(cleanupError)) + }); it is a text copy of the target's access rights and can be deleted.`, + ) +} + function _fsyncFile(fd: number): void { fsSync.fsyncSync(fd) } @@ -140,8 +215,12 @@ 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 +228,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 +291,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. */ @@ -259,6 +340,12 @@ export async function safeWriteText( // 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 + // The commit point of this write. Set the moment the rename succeeds: from that instant + // tempPath IS the target (the rename moved the staged file onto it), so the failure cleanup + // below must not unlink tempPath any more - a post-commit failure (the parent-directory fsync, + // the DACL restore, a throwing warning sink) would otherwise delete the file that was just + // published. The success path already knows this; the catch is the side that needs the flag. + let committed = false let tempPath: string if (options?.tempPath) { // A caller-supplied staging file is only safe when it is the file this @@ -276,13 +363,51 @@ 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"}`, 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. + // 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) + } + return null + }) + if ( + targetStat && + typeof stagingStat.ino === "bigint" && + typeof targetStat.ino === "bigint" && + stagingStat.ino === targetStat.ino && + stagingStat.dev === targetStat.dev + ) { + throw new StagingPathError("Staging file must not be the target itself", supplied) + } + // Ownership convention: a caller-staged file must carry the staging name this + // library's own writers use (.new__.tmp). An arbitrary regular file + // beside the target (e.g. credentials.json) passes every check above, yet + // renaming it onto the target publishes an unrelated file's content and deletes + // its original name. Only files created under the staging convention may be + // published through this parameter. + if (!/^\.new_.*\.tmp$/.test(path.basename(supplied))) { + throw new StagingPathError( + `Staging file must carry the staging name convention .new_*.tmp, got ${path.basename(supplied)}`, + supplied, + ) + } // The caller's own path is used as given; only the check is canonical. tempPath = options.tempPath } else { @@ -292,18 +417,34 @@ 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 - // 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 + // 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) => { + 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)) + 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) { + report("failed", error) + } + } try { // -- Step 1: write content to staging temp file ------------------- @@ -363,6 +504,21 @@ export async function safeWriteText( } const fd = fsSync.openSync(tempPath, "r+") try { + // Prove the opened file is not a borrowed name: between the lstat check + // and this open, a local attacker who can write the directory can replace + // the staging path with a hard link to another file in it, and the commit + // rename would then publish that file and delete its original name. While + // the other name exists the inode carries a second link, so nlink === 1 on + // the opened fd rules the substitution out; a swap that first unlinks the + // other name can only stage content the attacker already had, not a file + // the caller still references. + const opened = fsSync.fstatSync(fd) + if (!opened.isFile() || opened.nlink !== 1) { + throw new StagingPathError( + `Staging file changed between the check and the open: ${tempPath}`, + tempPath, + ) + } if (targetMode !== null) { fsSync.fchmodSync(fd, targetMode) } @@ -372,11 +528,16 @@ 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") { + 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) { @@ -384,24 +545,95 @@ export async function safeWriteText( // 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(() => {}) + // A failed icacls may have left a partial dump behind; remove it now so + // no partial dump survives and no later step can restore from it. The + // cleanup is the same retrying, reporting one used for a successful dump: + // a partial file is still a concrete file beside the target, and a + // transient EPERM (antivirus, a handle still being released) that survives + // the retry has to leave the exact path behind, not vanish into a catch. + await _discardDaclDump(dumpPath, warn) + // 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): 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) + try { + // Nothing runs here today: the descriptor exists only to create the destination with + // a fixed mode. The block is what guarantees the close below runs for every + // successful openSync, including any statement added between the open and the close - + // an unclosed descriptor holds the backup open and blocks the cleanup that has to + // remove that file. + } finally { + 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. The unlink is + // retried once (Windows reports EPERM for a file whose handle has not been + // released yet); if it still fails the path is carried on the thrown error + // instead of being dropped where no caller can act on it. + const orphanPath = backupPath + let backupCleanupError: unknown = null + for (let attempt = 0; attempt < 2; attempt++) { + try { + await fs.unlink(orphanPath) + backupCleanupError = null + break + } catch (cleanupError: unknown) { + backupCleanupError = errorCode(cleanupError) === "ENOENT" ? null : cleanupError + } + } + backupPath = null + if (backupCleanupError !== null) { + throw new OrphanedBackupError(orphanPath, targetPath, backupError, backupCleanupError) + } + throw backupError + } releaseBackupOnSuccess = true } catch (err: unknown) { if (errorCode(err) !== "ENOENT") throw err @@ -439,21 +671,56 @@ 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. + 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 ----------- if (releaseBackupOnSuccess && backupPath) { - try { - await fs.unlink(backupPath) - } catch { - // non-fatal — orphaned backup is acceptable + // The backup is a full copy of the previous content sitting next to the + // published file. Dropping its path on a failed unlink would leave an artifact + // that no caller can find or remove, so the unlink is retried once (Windows + // commonly reports EPERM while another handle is still being released) and a + // persistent failure is reported with the path instead of swallowed. The publish + // itself succeeded, so the write still resolves: this is a leftover to clean up, + // not a failed save. + let backupRemoved = false + for (let attempt = 0; attempt < 2 && !backupRemoved; attempt++) { + try { + await fs.unlink(backupPath) + backupRemoved = true + } catch (cleanupError: unknown) { + if (errorCode(cleanupError) === "ENOENT") { + // Already gone: the cleanup goal is met, nothing to report. + backupRemoved = true + } else if (attempt === 1) { + warn( + `safeWriteText: committed ${targetPath} but could not remove its backup copy at ${backupPath} (${ + errorCode(cleanupError) ?? "unknown error" + }); the copy of the previous content is still on disk and needs to be removed.`, + ) + } + } } } } finally { - // Unlink DACL dump regardless of success/failure in this span. + // Unlink DACL dump regardless of success/failure in this span. A dump that survives is an + // artifact nobody else can name, so a failure is retried and reported with its path. if (daclDumpPath !== null) { - await fs.unlink(daclDumpPath).catch(() => {}) + await _discardDaclDump(daclDumpPath, warn) + // This span owns the removal: the catch below must not retry the same file and report + // the same leftover twice. + daclDumpPath = null } } @@ -462,53 +729,92 @@ export async function safeWriteText( // 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. + // never un-commit a published file, so it is reported through the warning sink with + // the leftover path instead of being swallowed. if (stagingDir) { - await fs.rmdir(stagingDir).catch(() => {}) + await _releaseStagingDir(stagingDir, warn) } } 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. - 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 } + // The backup is a COPY taken before the commit, never a rename of the target, + // so there is nothing to restore here: the target still holds whatever the + // commit left - the pre-write content when the commit never ran, the published + // content when it did. The copy has served its purpose and must not be left + // beside the target where no caller can find it. + if (backupPath && releaseBackupOnSuccess) { + // Retry once (Windows reports EPERM while a handle is still being released), and + // if it still fails keep the path: clearing it would drop the only reference to + // the orphan. The original write error is what propagates; the leftover is + // reported through the warning sink with both paths. + let cleanupError: unknown = null + for (let attempt = 0; attempt < 2; attempt++) { + try { + await fs.unlink(backupPath) + cleanupError = null + break + } catch (error: unknown) { + // A copy that is already gone is not a leftover: reporting it would claim a + // recoverable orphan that no longer exists, and leaving backupPath set would + // tell the reader the copy is still there. + if (errorCode(error) === "ENOENT") { + cleanupError = null + break + } + cleanupError = error + } + } + if (cleanupError) { + warn( + `safeWriteText: the write to ${targetPath} failed and its backup copy could not be removed at ${backupPath} (${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }); the copy is left in place so the previous content is still recoverable by hand`, + ) + } else { + backupPath = null } } - // Only a write that never committed has a staged file to remove. After the - // commit rename the staged path names the published target: unlinking it - // here would at best miss with ENOENT, and at worst delete a file a - // concurrent writer has already claimed under the same staging name. + // Only a write that never committed has a staged file to remove. After the commit + // rename there is nothing at tempPath but the published target, and unlinking it here + // would un-commit the write the caller is being told about. if (!committed) { - try { - await fs.unlink(tempPath).catch(() => {}) - } catch { - // cleanup failure is non-fatal + let cleanupError: unknown = null + for (let attempt = 0; attempt < 2; attempt++) { + try { + await fs.unlink(tempPath) + cleanupError = null + break + } catch (error: unknown) { + // ENOENT means the goal is already met - the temp is gone - which is not a + // leftover and must not raise the warning below. + if (errorCode(error) === "ENOENT") { + cleanupError = null + break + } + cleanupError = error + } + } + if (cleanupError) { + // A leftover staged temp is not the caller's failure, but its path must not + // be dropped silently: report it and keep the original error propagating. + warn( + `safeWriteText: could not remove the staging temp ${tempPath} (${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + })`, + ) } } // 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. + // gone, so the directory is empty and the removal stays best-effort: the original + // write error keeps propagating, and a directory that still cannot be removed is + // reported with its path rather than discarded. if (stagingDir) { - await fs.rmdir(stagingDir).catch(() => {}) + await _releaseStagingDir(stagingDir, warn) } + // Reported through the warning sink with its path, and never in place of originalError. if (daclDumpPath !== null) { - await fs.unlink(daclDumpPath).catch(() => {}) - } - - if (rollbackFailure) { - throw new RollbackFailureError(originalError, rollbackFailure.error, rollbackFailure.backupPath) + await _discardDaclDump(daclDumpPath, warn) } throw originalError diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 025aa898d6..3ba08ecea0 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -97,10 +97,21 @@ 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 8c08b8e9ac..daf12245d3 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -3,8 +3,8 @@ import { Writable } from "stream" import * as path from "path" import * as os from "os" +import { PostCommitDurabilityError } from "../../services/file-safety/safeWriteText" import { safeWriteJson } from "../safeWriteJson" -import { PostCommitDurabilityError, RollbackFailureError } from "../../services/file-safety/safeWriteText" // Capture actual implementations before the vi.mock factory runs, // so they are never wrapped by vi.fn() — avoids infinite recursion when @@ -159,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" } @@ -178,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" } @@ -192,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) @@ -350,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") @@ -439,9 +428,10 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // Test for rollback failure scenario (the rollback rename now lives in safeWriteText) - test("throws RollbackFailureError with the publish failure as cause when the rollback rename also 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)) @@ -452,37 +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 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() }) @@ -684,10 +661,6 @@ describe("safeWriteJson", () => { } }) - // 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 () => { From 2139034f65a1105d467648abdff9d5eeff07d10b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:16:07 +0800 Subject: [PATCH 22/38] test(safety): pin the identity guard with a convention-conforming alias Port of the #1912 spec fix: the target-itself test passed a name the convention check also rejects, so the class-only assertion could not tell which guard fired. Stage a .new_*.tmp alias with the target's inode and assert the identity-specific message (CodeRabbit thread 4300138183). --- .../file-safety/__tests__/safeWriteText.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index f02bb8af2a..4063217d4c 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1672,9 +1672,13 @@ 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, - ) + // A convention-conforming name: only the identity guard can reject this alias + // of the target, so the assertion pins the identity message rather than the + // error class - a non-conforming name would be rejected by the name check for + // the wrong reason and the guard could be deleted without reddening this test. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/.new_alias_1.tmp", platform: "linux" }), + ).rejects.toThrow("Staging file must not be the target itself") 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 From ed9b23404642cafb8f99296c63d6bd866f54b55a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:06:22 +0800 Subject: [PATCH 23/38] test(safety): assert cleanup after the hard-link rejection Port of the #1912 test strengthening: the rejection lands after the open, so pin both outcomes: the descriptor is closed and the borrowed alias is unlinked (CodeRabbit thread 4300157108). --- src/services/file-safety/__tests__/safeWriteText.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 4063217d4c..c9ebcadafe 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1717,10 +1717,16 @@ describe("caller-supplied staging path", () => { const linked = _fileStats(false) linked.nlink = 2 vi.mocked(fsSync.fstatSync).mockReturnValue(linked) + vi.mocked(fsSync.openSync).mockReturnValue(7) await expect( safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/.new_x_1.tmp", platform: "linux" }), ).rejects.toThrow("Staging file changed between the check and the open") expect(fs.rename).not.toHaveBeenCalled() + // The rejection lands after the open: the descriptor must be closed and the + // borrowed alias unlinked, so the failed write leaves no open handle and no + // leftover at the staging path. + expect(fsSync.closeSync).toHaveBeenCalledWith(7) + expect(fs.unlink).toHaveBeenCalledWith("/tmp/test-dir/.new_x_1.tmp") }) it("rejects when the target identity cannot be compared for a reason other than a missing target", async () => { From 6e5706ae3a4c784a2035cdb36a9e04b016e5eb65 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:49:34 +0800 Subject: [PATCH 24/38] fix(merge): restore the upstream hunks dropped by the main merge Keep the U2 safeWriteText delegation while adopting main data:unknown; keep the branch backup-name expectations; prune suppressions to the merged tree. --- src/eslint-suppressions.json | 62 +----------------------------------- src/utils/safeWriteJson.ts | 2 +- 2 files changed, 2 insertions(+), 62 deletions(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 67ea2e7e4b..8625ae3d8d 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1619,69 +1619,9 @@ "count": 2 } }, - "utils/__tests__/autoImportSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 15 - } - }, - "utils/__tests__/enhance-prompt.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "utils/__tests__/git.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 95 - } - }, - "utils/__tests__/json-schema.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "utils/__tests__/migrateSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "utils/__tests__/outputChannelLogger.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "utils/__tests__/safeWriteJson.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "utils/__tests__/shell.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "utils/__tests__/storage.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "utils/__tests__/tiktoken.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/config.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/export.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/safeWriteJson.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 1 } }, "vitest.setup.ts": { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index da4b85636a..38d9a949b9 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -49,7 +49,7 @@ export interface SafeWriteJsonOptions { * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ -async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { +async function safeWriteJson(filePath: string, data: unknown, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op From 7aa579d4d54397f1f209fbf7323fadabb6eff8fb Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:38:52 +0800 Subject: [PATCH 25/38] test(safe-write-json): type the unlink mock and restore it in finally PathLike replaces any (the suppression entry is removed) and a failing assertion can no longer leak the rejecting unlink implementation into the next test. --- src/eslint-suppressions.json | 5 --- src/utils/__tests__/safeWriteJson.test.ts | 40 +++++++++++++---------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 8625ae3d8d..59ea9fccf8 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1619,11 +1619,6 @@ "count": 2 } }, - "utils/__tests__/safeWriteJson.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "vitest.setup.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 525ccf58f4..c40cfeb059 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -1,4 +1,5 @@ import * as fsSyncActual from "fs" +import type { PathLike } from "fs" import { Writable } from "stream" import * as path from "path" import * as os from "os" @@ -315,26 +316,31 @@ describe("safeWriteJson", () => { await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) - // 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("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) + // fs.unlink is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn. + // PathLike covers both toString() and the passthrough to the real unlink. + try { + vi.mocked(fs.unlink).mockImplementation(async (filePath: PathLike) => { + if (filePath.toString().includes("safeWriteText.bak_")) { + throw new Error("Backup deletion failed") + } + return fsPromisesActuals.unlink!(filePath) + }) - const content = await readFileContent(currentTestFilePath) - expect(content).toEqual(newData) + // The write must still succeed: backup cleanup is best-effort inside + // safeWriteText and never masks the committed content. + await safeWriteJson(currentTestFilePath, 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) + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(newData) - vi.mocked(fs.unlink).mockRestore() + // 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) + } finally { + // Restore even when an assertion fails: a leaked rejecting unlink would break the + // next test's backup-absence assertion. + vi.mocked(fs.unlink).mockRestore() + } }) // The expected error message might need to change if the mock behaves differently. From 0580426aa2fba8a40f1f87dc4740a6f80f5b72cc Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:40:03 +0800 Subject: [PATCH 26/38] fix(file-safety): re-resolve the commit target before the publish rename A symlink retargeted while the temp file streams could otherwise publish onto a sibling file; safeWriteText re-resolves immediately before the rename and aborts on a changed referent. --- src/services/file-safety/safeWriteText.ts | 10 ++++++++++ src/utils/__tests__/safeWriteJson.lockKey.spec.ts | 9 +++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 270e16f8dc..688f2eff07 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -641,6 +641,16 @@ export async function safeWriteText( } // -- Step 4: atomic rename temp -> target --------------------- + // A symlink can be retargeted while the temp file is being written: re-resolve + // immediately before the commit rename so the locked resolution cannot publish + // onto a sibling file that replaced the referent mid-write. + const recomputedTargetPath = await resolvePublishTarget(absoluteFilePath) + if (recomputedTargetPath !== targetPath) { + throw new Error( + `safeWriteText: refusing to commit ${absoluteFilePath}: the resolved target changed from ${targetPath} to ${recomputedTargetPath} while the write was in progress`, + ) + } + await fs.rename(tempPath, targetPath) committed = true diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 3ba08ecea0..20676c159b 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -97,10 +97,10 @@ 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 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. + // The trailing lstat calls are safeWriteText's staging-path checks (the regular-file + // check on the temp file and the identity check that staging is not the target), and + // the final resolve re-checks the commit target immediately before the rename. All run + // after the key was resolved and the lock was taken, so they do not change the lock. expect(order).toEqual([ "resolve-failed", "lstat", @@ -111,6 +111,7 @@ describe("safeWriteJson lock key under a peer commit", () => { "resolve", "lstat", "lstat", + "resolve", ]) expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) }) From 7532ed26f82693e57d206e4a328256683a17d3bc Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:09:28 +0800 Subject: [PATCH 27/38] fix(safe-write): compare directory identity in the safeWriteText staging guard The windows required leg (node 22.23.4) failed at 0580426a in 20 tests, and both shapes trace to delegating the JSON commit to safeWriteText: - 8 in utils/__tests__/safeWriteJson.test.ts, raised at safeWriteText.ts:361: StagingPathError: Staging file must sit in the target's directory (C:\Users\runneradmin\AppData\Local\Temp\safeWriteJson-test-whV2CF), got C:\Users\RUNNER~1\AppData\Local\Temp\... - 12 in safeWriteJson.interruption.test.ts, all at line 187 with one payload: AssertionError: expected 'WRITE_COMPLETED' to contain 'EXIT_AT_REPLACEMENT_RENAME'. The guard's comparison has two operands produced by two different canonicalisations - the spelling the caller's staging-file derivation hands down (the short one, the message's 'got') and the spelling safeWriteText derives when it re-resolves its own argument (the long one, the message's expected directory) - and on NTFS one directory answers to more than one spelling: the 8.3 alias of any name longer than eight characters, a junction, or a link. One directory, two strings, in a single message. Measured on node 22.23.4 (.tool-versions pins 22.23.4, package.json engines repeats it, the hosted legs run it; this box's system node is v25.9.0) against a real short-name alias of a directory: path.resolve(SHORT) != path.resolve(LONG) fs.realpathSync(SHORT) != fs.realpathSync(LONG) fs.realpathSync.native(SHORT) == fs.realpathSync.native(LONG) fs.stat(SHORT).dev == fs.stat(LONG).dev, and .ino == .ino Node 22's JavaScript fs.realpath preserves whichever spelling it is handed, so comparing realpaths would not have unified the runner's pair either. Only stat identity unifies an 8.3 alias, a junction and a case difference at once. Why not just stage from the resolved directory (option a): safeWriteJson.ts already does that - :183 builds the staging name from resolvedTargetPath and :197 passes that same variable as the target - and the runner still splits the spellings, because safeWriteText re-resolves the path it is handed and that re-resolution can take a different route: an existing target canonicalises, a target that does not exist yet falls through to path.resolve. The mismatch is therefore not fixable from the call site, and it is not evidence that the guard is too strict: the guard is textual on purpose. What it protects is a property of directory identity - no cross-filesystem commit rename (EXDEV), and never publishing an unrelated file onto the target - so a textual mismatch is now settled by comparing dev+ino read as bigint, the idiom the staged-file check below it already uses, and the rejection stands whenever either directory cannot be stat'd. A staging file in a genuinely different directory is still rejected under both spellings. The interruption half: the fault hook is the child's monkey-patch of fsPromises.rename inside safeWriteJson.interruption.test.ts, not the options.trace replacement-rename line - EXIT_AT_REPLACEMENT_RENAME is printed by that hook. The delegation moved the commit rename's destination from the caller's string to safeWriteText's own resolved publish target, so the hook's textual match against INTERRUPTION_TARGET_PATH misses whenever the directory is spelled through an alias: the SIGKILL never fires, the write completes, and the parent sees WRITE_COMPLETED. That is a behavioural consequence of the delegation, not a test-only looseness, and ubuntu cannot see it because its temp directory has no alias. The harness now recognises the commit rename by canonical destination, and a new win32 case spells the target through a junction so the assertion is red without the harness fix (reproduced: 1 failed | 12 passed with the CI assertion text above; 13 passed after). Tests, all on node 22.23.4: - new services/file-safety/__tests__/safeWriteText.alias.spec.ts reproduces the guard failure for real on this box at the pre-fix guard (blob 688f2eff0798): same StagingPathError thrown at safeWriteText.ts:361, expected directory in the long spelling, got directory in the short one. The suite skips itself where the volume generates no 8.3 names, which is why ubuntu - and this box's own long-spelled temp path - never showed it. - negative control at 688f2eff0798 across both specs: 2 failed | 77 passed; with the fix: 79 passed. - the misc project, which is the leg that was red, and the core project were rerun on this tree; counts are in the push report. The staging-name convention (the anchored .new_*.tmp rule in safeWriteText.ts) and its spec pin are untouched. The guard's contract and the canonicalisation choice belong to upstream, are tracked in #2350, and the cross-unit note is filed on #1405. --- .../__tests__/safeWriteText.alias.spec.ts | 113 ++++++++++++++++++ .../__tests__/safeWriteText.spec.ts | 76 ++++++++++++ src/services/file-safety/safeWriteText.ts | 34 +++++- .../safeWriteJson.interruption.test.ts | 111 ++++++++++++++++- 4 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 src/services/file-safety/__tests__/safeWriteText.alias.spec.ts diff --git a/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts b/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts new file mode 100644 index 0000000000..5b98afbf81 --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts @@ -0,0 +1,113 @@ +import { execFileSync } from "child_process" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" + +import { safeWriteText, StagingPathError } from "../safeWriteText" + +// NTFS hands an 8.3 alias to every component whose name is longer than eight characters, so one +// directory answers to two spellings. The hosted Windows runner is the case that matters: its +// temp directory arrives in the short form under the profile while the writer's resolved target +// arrives in the long spelling, and the staging file created beside that target carries whichever +// spelling the caller's canonicalisation produced. A textual comparison of the two directories then +// rejects a staging file that really is beside the target - that is the StagingPathError the +// windows job reports at the staging guard. +// +// The alias cannot be invented on a box whose volume declines to generate 8.3 names, so this file +// asks cmd for the short spelling of a directory it just created and skips the suite where the +// volume does not generate one. What node 22.23.4 - the version .tool-versions and engines pin - +// does with those two spellings is asserted below rather than assumed: path.resolve preserves +// whichever spelling it is handed, fs.realpath preserves it too, and fs.stat reports one identity +// for both. The guard compares identities for exactly that reason. + +function shortPathOf(directory: string): string | null { + const out = execFileSync("cmd.exe", ["/c", "for %A in (" + directory + ") do @echo %~sA"], { + encoding: "utf8", + }).trim() + const marker = "~1" + return out.includes(marker) ? out : null +} + +const probeBase = fs.mkdtempSync(path.join(os.tmpdir(), "safe-write-alias-probe-")) +const probeDir = path.join(probeBase, "EightThreeNameProbe") +fs.mkdirSync(probeDir) +const EIGHT_THREE_NAMES_ARE_GENERATED = shortPathOf(probeDir) !== null +fs.rmSync(probeBase, { recursive: true, force: true }) + +describe("safeWriteText staging guard against a second spelling of the target directory", () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "safeWriteText-alias-")) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + function createTargetDirectory(): { longPath: string; shortPath: string | null } { + const longPath = path.join(tempDir, "LONGTARGETDIRECTORYNAME") + fs.mkdirSync(longPath) + return { longPath, shortPath: shortPathOf(longPath) } + } + + it.runIf(EIGHT_THREE_NAMES_ARE_GENERATED)( + "publishes a staging file spelled through the target directory own 8.3 alias", + async () => { + const { longPath, shortPath } = createTargetDirectory() + if (shortPath === null) { + return + } + const targetPath = path.join(longPath, "target.txt") + fs.writeFileSync(targetPath, "committed", "utf8") + // The staging file sits beside the target but is spelled through the directory's 8.3 + // alias, which is what a caller's own canonicalisation hands the guard. + const stagingPath = path.join(shortPath, ".new_target.txt_1_x.tmp") + fs.writeFileSync(stagingPath, "staged", "utf8") + + // Preconditions, asserted rather than assumed: the two directory spellings differ as text + // and this runtime's fs.realpath does not unify them... + expect(path.dirname(stagingPath)).not.toBe(path.dirname(targetPath)) + const realViaAlias = fs.realpathSync(path.dirname(stagingPath)) + const realViaTarget = fs.realpathSync(path.dirname(targetPath)) + expect(realViaAlias).not.toBe(realViaTarget) + // ...while fs.stat reports the same directory for both spellings. That identity is what + // the guard compares, and it is why a rename from here onto the target cannot be EXDEV. + const statViaAlias = fs.statSync(path.dirname(stagingPath), { bigint: true }) + const statViaTarget = fs.statSync(path.dirname(targetPath), { bigint: true }) + expect(statViaAlias.dev).toBe(statViaTarget.dev) + expect(statViaAlias.ino).toBe(statViaTarget.ino) + + await safeWriteText(targetPath, "", { tempPath: stagingPath, backup: false }) + + expect(fs.readFileSync(targetPath, "utf8")).toBe("staged") + expect(fs.existsSync(stagingPath)).toBe(false) + }, + ) + + it.runIf(EIGHT_THREE_NAMES_ARE_GENERATED)( + "still rejects a staging file spelled through another directory 8.3 alias", + async () => { + const { longPath } = createTargetDirectory() + const otherPath = path.join(tempDir, "OTHERTARGETDIRECTORYNAME") + fs.mkdirSync(otherPath) + const otherShort = shortPathOf(otherPath) + if (otherShort === null) { + return + } + const targetPath = path.join(longPath, "target.txt") + fs.writeFileSync(targetPath, "committed", "utf8") + const stagingPath = path.join(otherShort, ".new_target.txt_1_x.tmp") + fs.writeFileSync(stagingPath, "staged", "utf8") + + // Same alias mechanism, different directory. Accepting the target directory's alias must + // not become accepting any directory, or a caller could publish an unrelated file onto the + // target through a name the guard was written to refuse. + await expect(safeWriteText(targetPath, "", { tempPath: stagingPath, backup: false })).rejects.toThrow( + StagingPathError, + ) + expect(fs.readFileSync(targetPath, "utf8")).toBe("committed") + expect(fs.existsSync(stagingPath)).toBe(true) + }, + ) +}) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index c9ebcadafe..d5f845d4f3 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -22,6 +22,7 @@ vi.mock("fs/promises", () => ({ unlink: vi.fn(), rmdir: vi.fn(), realpath: vi.fn(), + stat: vi.fn(), lstat: vi.fn(), readlink: vi.fn(), })) @@ -105,6 +106,11 @@ function mockDefaults(): void { vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) // Post-open default: the opened file is the same single-named regular file. vi.mocked(fsSync.fstatSync).mockReturnValue(_fileStats(false)) + // Directory-identity default: unresolvable, so the identity check keeps the textual rejection + // wherever a test does not stage an alias of the target's own directory. + vi.mocked(fs.stat).mockRejectedValue( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) } function _stats(mode: number): fsSync.Stats { const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats @@ -1707,6 +1713,76 @@ describe("caller-supplied staging path", () => { expect(fs.rename).not.toHaveBeenCalled() }) + // NTFS hands an 8.3 alias to every path component longer than eight characters, and the hosted + // Windows runner's temp directory arrives in that spelling while the writer's resolved target + // arrives in the long spelling of the same directory; a junction or link does the same thing on + // any box. Two strings, one directory: the textual check above rejects a staging file that really + // is beside the target. What that check protects is the opposite - a staging file that is NOT + // beside the target, which is what makes the commit rename cross-filesystem (EXDEV) or lets a + // caller publish an unrelated file onto the target. Identity is that property, so a textual + // mismatch is resolved by comparing the two directories' dev and ino. path.resolve cannot settle + // it (it preserves whichever spelling it is given) and neither can fs.realpath: measured on node + // 22.23.4, Node's JavaScript realpath preserves an NTFS 8.3 spelling, and only + // fs.realpathSync.native expands it. These two tests drive the identity branch directly because a + // whole-file mock of fs/promises cannot present two spellings of a real directory. + it("accepts a caller-staged file whose directory is the target directory under another spelling", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const aliasDir = path.resolve("/tmp/alias-dir") + const targetDir = path.dirname(path.resolve(targetPath)) + // One directory under two spellings: each side hands back its own realpath, so only the pair's + // identity says they are the same directory. + // realpath here mirrors what node 22.23.4 does to an NTFS 8.3 name: it hands back the + // spelling it was given, so only the directory identity can tell an alias from another directory. + vi.mocked(fs.realpath).mockImplementation((p: unknown) => Promise.resolve(path.resolve(String(p)))) + vi.mocked(fs.stat).mockImplementation((p: unknown) => { + const s = String(p) + if (s === aliasDir || s === targetDir) { + return Promise.resolve(_fileStatsWithIdentity(4242n, 7n)) + } + return Promise.reject(Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })) + }) + vi.mocked(fsSync.openSync).mockReturnValue(3) + + const customTempPath = path.join(aliasDir, ".new_target.txt_1_x.tmp") + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // The alias directory and the target directory are one directory, so the staging file is + // beside the target and the publish proceeds. + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fs.rename).toHaveBeenCalledWith(customTempPath, path.resolve(targetPath)) + }) + + it("still rejects a caller-staged file whose directory has a different identity", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const otherDir = path.resolve("/tmp/other-dir") + const targetDir = path.dirname(path.resolve(targetPath)) + // Two directories that both resolve cleanly but are not the same directory: a staging file in + // one of them would publish an unrelated file onto the target, and across a mount the commit + // rename would fail with EXDEV. + // realpath here mirrors what node 22.23.4 does to an NTFS 8.3 name: it hands back the + // spelling it was given, so only the directory identity can tell an alias from another directory. + vi.mocked(fs.realpath).mockImplementation((p: unknown) => Promise.resolve(path.resolve(String(p)))) + vi.mocked(fs.stat).mockImplementation((p: unknown) => { + const s = String(p) + if (s === otherDir) { + return Promise.resolve(_fileStatsWithIdentity(9999n, 7n)) + } + if (s === targetDir) { + return Promise.resolve(_fileStatsWithIdentity(4242n, 7n)) + } + return Promise.reject(Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })) + }) + vi.mocked(fsSync.openSync).mockReturnValue(3) + + const customTempPath = path.join(otherDir, ".new_target.txt_1_x.tmp") + + await expect(safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" })).rejects.toThrow( + "Staging file must sit in the target's directory", + ) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) it("rejects a caller-staged file that gained a second hard link before the open", 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 688f2eff07..3846158a7e 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -358,10 +358,36 @@ export async function safeWriteText( // 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, - ) + // A textual mismatch is not yet evidence that the staging file sits elsewhere, because one + // directory answers to more than one spelling. NTFS hands out an 8.3 alias for every name + // longer than eight characters - the hosted Windows runner's temp directory arrives as + // RUNNER~1 under the profile while the writer's resolved target arrives in the long + // spelling - and a junction or link does the same thing on any box. path.resolve preserves + // whichever spelling it is handed, and fs.realpath (the JavaScript implementation Node 22 + // uses) preserves it too; only realpathSync.native expands the 8.3 form, measured on + // node 22.23.4. So neither a textual nor a plain-realpath comparison can tell an alias of + // the target's own directory from a different directory. The property this guard protects - + // no cross-filesystem commit rename (EXDEV) and no publishing of an unrelated file onto the + // target - is a property of the directory's identity, so compare identity: dev and ino, read + // as bigint for the same reason as the file check below. A directory that cannot be stat'd + // keeps the rejection, so this only ever accepts what the textual check would have accepted + // had the spelling been canonical on both sides. + const suppliedDir = path.dirname(supplied) + const [suppliedDirStat, targetDirStat] = await Promise.all([ + fs.stat(suppliedDir, { bigint: true }).catch(() => undefined), + fs.stat(dirPath, { bigint: true }).catch(() => undefined), + ]) + const sameDirectory = + suppliedDirStat !== undefined && + targetDirStat !== undefined && + suppliedDirStat.dev === targetDirStat.dev && + suppliedDirStat.ino === targetDirStat.ino + if (!sameDirectory) { + throw new StagingPathError( + `Staging file must sit in the target's directory (${dirPath}), got ${supplied}`, + 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 diff --git a/src/utils/__tests__/safeWriteJson.interruption.test.ts b/src/utils/__tests__/safeWriteJson.interruption.test.ts index c9c5993f84..472a1020c7 100644 --- a/src/utils/__tests__/safeWriteJson.interruption.test.ts +++ b/src/utils/__tests__/safeWriteJson.interruption.test.ts @@ -29,15 +29,43 @@ const fs = require("fs") const path = require("path") const targetPath = path.resolve(process.env.INTERRUPTION_TARGET_PATH) +// The commit goes through safeWriteText, which renames onto the publish target it resolved +// itself. One directory answers to two spellings on NTFS - every component longer than eight +// characters also has an 8.3 alias, so the CI runner's temp directory arrives short-named +// (RUNNER~1 under the user profile) while realpath hands back the long spelling of the same +// directory - and a junction or a link does the same thing on any box. The writer's canonical +// handed to this child can therefore name the same file as different text, and a textual +// comparison would then let the commit land unnoticed. Canonicalise this side the same way, +// and fall back to the textual form for a target that does not exist yet and cannot be +// canonicalised. +let canonicalTargetPath = targetPath +try { + canonicalTargetPath = fs.realpathSync(targetPath) +} catch { + // A target that is not on disk yet has no canonical spelling; the textual form is then + // the only comparison available, and it is the one the writer would use anyway. +} +const isCommitRename = (newPath) => { + const resolved = path.resolve(String(newPath)) + if (resolved === targetPath || resolved === canonicalTargetPath) { + return true + } + try { + return fs.realpathSync(resolved) === canonicalTargetPath + } catch { + // A destination that cannot be canonicalised is not the canonical target. + return false + } +} const fsPromises = require("fs/promises") const actualRename = fsPromises.rename // Fault injection: hard-kill before the replacement lands. Any rename -// whose destination is the canonical path is the commit step, whatever +// whose destination is the canonical path (or an alias of it) is the commit step, whatever // replacement protocol the writer uses; exit before performing it. let commitAttempts = 0 fsPromises.rename = async function interruptionRename(oldPath, newPath) { - if (path.resolve(String(newPath)) === targetPath) { + if (isCommitRename(newPath)) { commitAttempts++ if (process.env.INTERRUPTION_MODE === "fallback" && commitAttempts === 1) { return Promise.reject(Object.assign(new Error("Injected EPERM"), { code: "EPERM" })) @@ -239,4 +267,83 @@ describe("safeWriteJson subprocess interruption fault injection", () => { }, 45_000, ) + + // The child intercepts the commit rename by comparing the rename's destination with + // INTERRUPTION_TARGET_PATH. The commit now goes through safeWriteText, which renames onto the + // publish target it resolved itself, so the destination is not necessarily the spelling the + // parent passed down. On NTFS one directory answers to two spellings: every component longer + // than eight characters also has an 8.3 alias, and the Windows CI runner's temp directory + // arrives as C:\Users\RUNNER~1\... while realpath hands back C:\Users\runneradmin\... The + // interception has to survive that. A junction gives any Windows box the same shape without + // needing privileges: this case spells the target through an alias of the directory the + // writer resolves to, so the parent's spelling and the writer's canonical spelling of the same + // directory differ as text on a box that has no 8.3 alias on its own temp path. + it.runIf(process.platform === "win32")( + "intercepts the commit rename when the target path is spelled through a directory alias", + async () => { + const fileName = "ui_messages.json" + const message = { + ts: 1, + type: "say", + say: "text", + text: "RESTART_PERSISTENCE_MARKER", + role: "assistant", + content: "RESTART_PERSISTENCE_MARKER", + } + const committed = [message] + const replacement = [{ ...message, ts: 2, text: "ALIASED_WRITE", content: "ALIASED_WRITE" }] + await safeWriteJson(targetPath, committed) + + const aliasDir = path.join(tempDir, "storage-alias") + try { + await fs.symlink(storageDir, aliasDir, "junction") + } catch (error: unknown) { + const code = typeof error === "object" && error !== null ? (error as { code?: string }).code : undefined + // A box that cannot create a junction cannot present a second spelling of the + // directory; there is nothing to intercept differently and no alias to reject. + if (code === "EPERM" || code === "ENOSYS" || code === "EEXIST") { + return + } + throw error + } + const aliasTargetPath = path.join(aliasDir, fileName) + expect(path.resolve(aliasTargetPath)).not.toBe(path.resolve(targetPath)) + + const child = spawn(process.execPath, [childScriptPath], { + env: { + ...process.env, + INTERRUPTION_TARGET_PATH: aliasTargetPath, + INTERRUPTION_MODE: "atomic", + INTERRUPTION_BOUNDARY: "before-commit", + INTERRUPTION_PAYLOAD: JSON.stringify(replacement), + ESBUILD_PATH: esbuildPath, + SAFE_WRITE_JSON_ENTRY: safeWriteJsonEntryPath, + INTERRUPTION_BUNDLE_PATH: childBundlePath, + }, + stdio: ["ignore", "pipe", "pipe"], + }) + const exit = await waitForChildExit(child) + + expect(exit.stderr).not.toContain("WRITE_FAILED") + expect(exit.stdout).toContain("EXIT_AT_REPLACEMENT_RENAME") + expect(exit.stdout).not.toContain("WRITE_COMPLETED") + expect(exit.code).toBe(1) + // The canonical target is untouched by an interrupted commit, spelled through either name. + expect(JSON.parse(await readFileWithMissingRetry(targetPath))).toEqual(committed) + expect(JSON.parse(await readFileWithMissingRetry(aliasTargetPath))).toEqual(committed) + const interruptedFiles = await fs.readdir(storageDir) + expect(interruptedFiles.filter((entry) => entry.includes(".new_"))).toHaveLength(1) + + const staleTime = new Date(Date.now() - 60_000) + await fs.utimes(`${targetPath}.lock`, staleTime, staleTime) + const recovered = { ui_messages: [{ ts: 3, type: "say", text: "RECOVERED_AFTER_RESTART" }] } + await safeWriteJson(aliasTargetPath, recovered) + expect(JSON.parse(await readFileWithMissingRetry(targetPath))).toEqual(recovered) + const leftovers = (await fs.readdir(storageDir)).filter( + (entry) => entry.includes(".new_") || entry.includes(".bak_"), + ) + expect(leftovers).toEqual([]) + }, + 45_000, + ) }) From a06845b79c074a2626a663633f685be7edb2a668 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:26:30 +0800 Subject: [PATCH 28/38] file-safety: make the 8.3 alias probe answer instead of throwing The alias spec added in the previous commit failed on Linux as a suite, not as a test: ubuntu job 128587444594 (run 42433550079, test:coverage:services) reported Error: spawnSync cmd.exe ENOENT shortPathOf services/file-safety/__tests__/safeWriteText.alias.spec.ts:24:14 safeWriteText.alias.spec.ts:34:41 Test Files 1 failed | 82 passed (83) Tests 1684 passed | 2 skipped (1686) with no failed tests, and the windows job (128587444705) was cancelled by fail-fast 5s later, so it never reached a verdict on the guard fix. The cause is that the probe ran while the module was being imported and shortPathOf called execFileSync("cmd.exe", ...) with nothing around it. A Linux worker has no cmd.exe, the import threw, vitest recorded a failed suite, and the it.runIf(EIGHT_THREE_NAMES_ARE_GENERATED) that was supposed to skip the suite is the very expression whose argument died. The comment promised a skip where 8.3 names are not generated; on Linux the probe failed for a different reason - a missing binary - and the suite errored. Fix: make the probe total and platform-aware. shortPathOf answers null on any platform that is not win32 without spawning anything, and answers null for every spawn failure instead of throwing; probeGeneratesEightThreeNames is likewise total and creates its probe directory only on win32. The header comment now states what the skip actually covers: non-win32, and win32 volumes with 8.3 name generation switched off. Controls on pinned node v22.23.4 (the version .tool-versions and engines pin), running only this spec through vitest.services.config.ts: - HEAD probe + a binary that does not exist: exit 1, Failed Suites 1, 'spawnSync cmd-not-there.exe ENOENT', Test Files 1 failed (1), no Tests line - the Linux failure shape reproduced without Linux. - fixed probe + the same missing binary: exit 0, 1 skipped file, 2 skipped, 0 failed - the totality is what turns the same condition from a failed suite into a skip. - fixed probe with the platform test forced false: 2 skipped, 0 failed - the Linux path. - same, with the platform early-returns removed: 2 passed - on a Windows box the spawn succeeds and finds a real alias, so the skips above come from the gate and not from an incidental failure. The spec was restored afterwards and its sha256 re-verified (57a174e75544d36f...). Gates: eslint --prune-suppressions --max-warnings=0 on the file exit 0 with no count delta and eslint-suppressions.json left at blob 42be14463c; prettier --check clean; src tsc --noEmit exit 0. Mutation preflight skipped and said so: the unit is already far over the advisory cap, so a local Stryker run cannot change the verdict. Refs: #1911 --- .../__tests__/safeWriteText.alias.spec.ts | 61 +++++++++++++++---- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts b/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts index 5b98afbf81..19a95611a5 100644 --- a/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts @@ -13,26 +13,61 @@ import { safeWriteText, StagingPathError } from "../safeWriteText" // rejects a staging file that really is beside the target - that is the StagingPathError the // windows job reports at the staging guard. // -// The alias cannot be invented on a box whose volume declines to generate 8.3 names, so this file -// asks cmd for the short spelling of a directory it just created and skips the suite where the -// volume does not generate one. What node 22.23.4 - the version .tool-versions and engines pin - +// The alias cannot be invented where 8.3 names are not generated, so this file asks cmd for the +// short spelling of a directory it just created and skips the suite where no alias appears: on any +// platform that is not win32, where cmd.exe does not exist and no alias could exist either, and on +// a win32 volume that has 8.3 name generation switched off. The probe has to answer that question +// without throwing: until now the probe ran while the module was being imported, so a worker with no +// cmd.exe failed the import and vitest recorded a failed suite instead of skipping the suite. +// What node 22.23.4 - the version .tool-versions and engines pin - // does with those two spellings is asserted below rather than assumed: path.resolve preserves // whichever spelling it is handed, fs.realpath preserves it too, and fs.stat reports one identity // for both. The guard compares identities for exactly that reason. +const IS_WINDOWS = process.platform === "win32" +const SHORT_NAME_MARKER = "~1" +const SHORT_NAME_PROBE_BIN = "cmd.exe" + +// Total: every failure mode answers null so that the caller skips instead of dying. A non-win32 +// worker has neither cmd.exe nor 8.3 aliases and is answered without spawning anything; a cmd that +// is missing, refuses the query, or answers without an alias means no alias to test against. function shortPathOf(directory: string): string | null { - const out = execFileSync("cmd.exe", ["/c", "for %A in (" + directory + ") do @echo %~sA"], { - encoding: "utf8", - }).trim() - const marker = "~1" - return out.includes(marker) ? out : null + if (!IS_WINDOWS) { + return null + } + try { + const out = execFileSync(SHORT_NAME_PROBE_BIN, ["/c", "for %A in (" + directory + ") do @echo %~sA"], { + encoding: "utf8", + }).trim() + return out.includes(SHORT_NAME_MARKER) ? out : null + } catch { + return null + } +} + +// Answered once at import so that it.runIf can decide before any test body runs. Never allowed to +// throw: a throwing probe is a failed import, which vitest reports as a failed suite that no skip +// condition can rescue. +function probeGeneratesEightThreeNames(): boolean { + if (!IS_WINDOWS) { + return false + } + let probeBase: string | undefined + try { + probeBase = fs.mkdtempSync(path.join(os.tmpdir(), "safe-write-alias-probe-")) + const probeDir = path.join(probeBase, "EightThreeNameProbe") + fs.mkdirSync(probeDir) + return shortPathOf(probeDir) !== null + } catch { + return false + } finally { + if (probeBase !== undefined) { + fs.rmSync(probeBase, { recursive: true, force: true }) + } + } } -const probeBase = fs.mkdtempSync(path.join(os.tmpdir(), "safe-write-alias-probe-")) -const probeDir = path.join(probeBase, "EightThreeNameProbe") -fs.mkdirSync(probeDir) -const EIGHT_THREE_NAMES_ARE_GENERATED = shortPathOf(probeDir) !== null -fs.rmSync(probeBase, { recursive: true, force: true }) +const EIGHT_THREE_NAMES_ARE_GENERATED = probeGeneratesEightThreeNames() describe("safeWriteText staging guard against a second spelling of the target directory", () => { let tempDir: string From d29b7a3427c55878c04f1e1d05094b4a383cc6c9 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:43:06 +0800 Subject: [PATCH 29/38] test: intercept the commit rename by identity, not by spelling The windows leg at a06845b79 came back 13 failed instead of 20, and the two clusters split apart cleanly. job 128591015715 (run 42434722070) shows zero StagingPathError: the eight staging-guard failures are gone, so the guard fix works on the runner. What is left is one file and one payload AssertionError: expected 'WRITE_COMPLETED\n' to contain 'EXIT_AT_REPLACEMENT_RENAME' at safeWriteJson.interruption.test.ts:215 for the twelve recovery cases and :328 for the junction case. WRITE_COMPLETED means the child's fault hook never recognised the commit rename at all, so the write finished. ubuntu passed the same file, and it passes locally here, because neither box presents the condition; the runner does. The hook decided by text plus fs.realpath: compare the resolved destination against the parent's path and against realpathSync(target), and otherwise compare realpathSync(destination) with realpathSync(target). That cannot work where one directory answers under two spellings. Measured on a real 8.3 alias pair on this box, feeding the shipped matcher both spellings: target spelled LONG, destination spelled through the alias: committed matcher false target spelled SHORT, destination spelled the long way: committed matcher false a file in another directory, either spelling: false (correct) and the runner's own data says the same thing from the other side: the staging-guard message that used to appear there expected the long spelling of the temp directory and got RUNNER~1 for the same directory, which is what its canonicaliser handed back. Two canonicalisations that do not agree, so no amount of canonicalising on one side matches the other. fs.stat reports one identity for both spellings, so the interception decision is now taken on identity: same path text, or same case-insensitive path text on win32, or the same dev+ino, or - when the destination is not on disk yet and has no identity of its own - the same directory identity plus the same basename. The negative case is unchanged: a staging file under another directory's alias is still not the commit. Pinned by a test instead of by a platform that cannot show the condition. The child now answers a matcher self-check (INTERRUPTION_MODE=matcher) without building or writing anything, and 'recognises the commit rename through a short-name alias, and refuses another directory' creates two long-named directories, asks cmd for their 8.3 spellings, and asserts the decisions from both spellings. Against the shipped matcher it fails with 'alias: false' (S268: 1 failed | 13 passed); after the change all 14 pass (S270, S272). It returns early on non-win32 and when a volume generates no short names, so it cannot fail the way the alias spec did on ubuntu. Gates (pinned v22.23.4): vitest.misc.config.ts on this file 14 passed; src tsc --noEmit exit 0; eslint --prune-suppressions --max-warnings=0 on the file exit 0 with no count delta (265 entries both sides) and eslint-suppressions.json left at blob 42be14463c29; prettier clean; knip exit 0. Note for whoever reads the next windows run: this leg aborted at test:misc, so the services shard - and with it safeWriteText.alias.spec.ts - has not executed on windows yet at any head. Mutation preflight skipped and said out loud: the unit is far over the advisory cap. Refs: #1911 --- .../safeWriteJson.interruption.test.ts | 155 ++++++++++++++++-- 1 file changed, 141 insertions(+), 14 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.interruption.test.ts b/src/utils/__tests__/safeWriteJson.interruption.test.ts index 472a1020c7..5840f9bea6 100644 --- a/src/utils/__tests__/safeWriteJson.interruption.test.ts +++ b/src/utils/__tests__/safeWriteJson.interruption.test.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess } from "child_process" +import { execFileSync, spawn, type ChildProcess } from "child_process" import { createRequire } from "module" import { fileURLToPath } from "url" import * as fs from "fs/promises" @@ -38,25 +38,54 @@ const targetPath = path.resolve(process.env.INTERRUPTION_TARGET_PATH) // comparison would then let the commit land unnoticed. Canonicalise this side the same way, // and fall back to the textual form for a target that does not exist yet and cannot be // canonicalised. -let canonicalTargetPath = targetPath -try { - canonicalTargetPath = fs.realpathSync(targetPath) -} catch { - // A target that is not on disk yet has no canonical spelling; the textual form is then - // the only comparison available, and it is the one the writer would use anyway. +const identityOf = (entry) => { + try { + const stat = fs.statSync(entry, { bigint: true }) + return stat.dev + ":" + stat.ino + } catch { + return null + } } -const isCommitRename = (newPath) => { - const resolved = path.resolve(String(newPath)) - if (resolved === targetPath || resolved === canonicalTargetPath) { +const sameEntry = (candidate, expected) => { + const resolved = path.resolve(String(candidate)) + const wanted = path.resolve(String(expected)) + if (resolved === wanted) { return true } - try { - return fs.realpathSync(resolved) === canonicalTargetPath - } catch { - // A destination that cannot be canonicalised is not the canonical target. + if (process.platform === "win32" && resolved.toLowerCase() === wanted.toLowerCase()) { + return true + } + const candidateIdentity = identityOf(resolved) + const wantedIdentity = identityOf(wanted) + if (candidateIdentity !== null && wantedIdentity !== null && candidateIdentity === wantedIdentity) { + return true + } + // A destination that is not on disk yet has no identity of its own, so compare the directory it + // would land in and the name it would land under: same directory identity and same basename is + // the same file, whatever spelling either side used. + const candidateName = path.basename(resolved) + const wantedName = path.basename(wanted) + if ( + process.platform === "win32" + ? candidateName.toLowerCase() !== wantedName.toLowerCase() + : candidateName !== wantedName + ) { return false } + const candidateDirIdentity = identityOf(path.dirname(resolved)) + const wantedDirIdentity = identityOf(path.dirname(wanted)) + return ( + candidateDirIdentity !== null && wantedDirIdentity !== null && candidateDirIdentity === wantedDirIdentity + ) } +// Text and fs.realpath cannot decide this one. On the hosted Windows runner one directory answers +// under two spellings and the canonicaliser hands back the short form for the profile component +// (RUNNER~1), so the writer's canonical destination and the parent's canonical target are different +// text for one file. Measured here: realpath returns whichever spelling it is handed while fs.stat +// reports a single identity for both; measured on the runner: the staging guard reported the long +// spelling expected against a short got for the same directory. Identity is the only spelling both +// sides agree on, so the interception decision is taken on identity. +const isCommitRename = (newPath) => sameEntry(newPath, targetPath) const fsPromises = require("fs/promises") const actualRename = fsPromises.rename @@ -79,6 +108,17 @@ fsPromises.rename = async function interruptionRename(oldPath, newPath) { return actualRename.apply(fsPromises, arguments) } +// Matcher self-check mode: answer the interception decision for candidates handed in from the +// parent, without building or writing anything. This is how the alias decisions are pinned by a +// test on a real second spelling instead of being trusted to a platform that cannot show one. +if (process.env.INTERRUPTION_MODE === "matcher") { + const cases = JSON.parse(process.env.INTERRUPTION_MATCHER_CASES || "[]") + for (const testCase of cases) { + fs.writeSync(1, "MATCH " + testCase.label + "=" + isCommitRename(testCase.candidate) + String.fromCharCode(10)) + } + process.exit(0) +} + require(process.env.ESBUILD_PATH).buildSync({ entryPoints: [process.env.SAFE_WRITE_JSON_ENTRY], bundle: true, @@ -346,4 +386,91 @@ describe("safeWriteJson subprocess interruption fault injection", () => { }, 45_000, ) + + // The interception decision, taken on a directory that answers to two spellings. The + // writer hands the child hook the spelling its own canonicalisation produced, and on the hosted + // Windows runner that spelling is the 8.3 alias of the directory the parent created, so the hook + // has to recognise the commit rename from the file identity rather than from the text it is + // handed. Windows only: there is no short-name alias elsewhere, and a volume that does not + // generate them cannot present one here either. + const shortSpellingOf = (directory: string): string | null => { + if (process.platform !== "win32") { + return null + } + try { + const out = execFileSync("cmd.exe", ["/c", "for %A in (" + directory + ") do @echo %~sA"], { + encoding: "utf8", + }).trim() + return out.includes("~1") ? out : null + } catch { + return null + } + } + + it("recognises the commit rename through a short-name alias, and refuses another directory", async () => { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "interruption-matcher-")) + const longDir = path.join(base, "LONGMATCHERDIRECTORYNAME") + const otherDir = path.join(base, "OTHERMATCHERDIRECTORYNAME") + await fs.mkdir(longDir, { recursive: true }) + await fs.mkdir(otherDir, { recursive: true }) + const shortDir = shortSpellingOf(longDir) + const shortOther = shortSpellingOf(otherDir) + if (shortDir === null || shortOther === null) { + return + } + const fileName = "target.json" + await fs.writeFile(path.join(longDir, fileName), "committed", "utf8") + + const runMatcher = async ( + targetPath: string, + candidates: Array<[string, string]>, + ): Promise> => { + const child = spawn(process.execPath, [childScriptPath], { + env: { + ...process.env, + INTERRUPTION_TARGET_PATH: targetPath, + INTERRUPTION_MODE: "matcher", + INTERRUPTION_MATCHER_CASES: JSON.stringify( + candidates.map(([label, candidate]) => ({ label, candidate })), + ), + ESBUILD_PATH: esbuildPath, + SAFE_WRITE_JSON_ENTRY: safeWriteJsonEntryPath, + INTERRUPTION_BUNDLE_PATH: childBundlePath, + }, + stdio: ["ignore", "pipe", "pipe"], + }) + const exit = await waitForChildExit(child) + const decisions: Record = {} + for (const line of exit.stdout.split(String.fromCharCode(10))) { + const match = line.match(/^MATCH ([^=]+)=(true|false)$/) + if (match) { + decisions[match[1]] = match[2] + } + } + return decisions + } + + // Same file, spelled through the alias: the decision must be yes from either side's spelling. + const fromLong = await runMatcher(path.join(longDir, fileName), [ + ["self", path.join(longDir, fileName)], + ["alias", path.join(shortDir, fileName)], + ["other-directory-file", path.join(otherDir, fileName)], + ["other-directory-alias", path.join(shortOther ?? otherDir, fileName)], + ]) + expect(fromLong).toEqual({ + self: "true", + alias: "true", + "other-directory-file": "false", + "other-directory-alias": "false", + }) + + const fromShort = await runMatcher(path.join(shortDir, fileName), [ + ["self", path.join(shortDir, fileName)], + ["alias", path.join(longDir, fileName)], + ["other-directory-file", path.join(otherDir, fileName)], + ]) + expect(fromShort).toEqual({ self: "true", alias: "true", "other-directory-file": "false" }) + + await fs.rm(base, { recursive: true, force: true }) + }, 30_000) }) From 9f150f5bf7d4196d398e396e5bbf97ee1c8d57f9 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:37:06 +0800 Subject: [PATCH 30/38] fix(file-safety): propagate unreadable links, keep landed commits, scope leftover names Review follow-ups from the #1911 threads, each with a row that fails without the fix: - resolveLockKey now propagates a readlink failure that names no chain-end code instead of ending the walk on it (ENOENT and EINVAL are the measured chain-end answers), and an exhausted 8-link walk rejects instead of naming an arbitrary intermediate link. - the short-name probe quotes its directory and runs verbatim, so a spaced directory answers one short path instead of one line per word; the probe cleanup cannot throw out of the module import; the matcher's scratch tree lives under the suite's own temp dir. - recovery leftovers must carry the writer's own __.tmp tail, so a sibling basename that extends this one (a.json_extra.json beside a.json) keeps its staging file instead of being unlinked under the wrong lock. - the EPERM-fallback backup is dropped, not restored, when the failure is PostCommitDurabilityError: the commit rename already landed the new content at the target, and renaming the backup back would put the old bytes under an error that says the new ones are there. - the symlink staging row uses a convention-conforming name so it pins the link guard rather than the name guard. Gates (pinned node 22.23.4, src worktree): vitest 163 passed / 1 skipped across the six file-safety and json-write suites; eslint --max-warnings=0 clean; prettier --check clean; tsc --noEmit unchanged from the donor-install baseline. --- .../__tests__/safeWriteText.alias.spec.ts | 66 ++++++++++++++++-- .../__tests__/safeWriteText.spec.ts | 69 +++++++++++++++---- src/services/file-safety/safeWriteText.ts | 31 ++++++--- src/utils/__tests__/jsonFileRecovery.test.ts | 17 +++++ .../safeWriteJson.interruption.test.ts | 25 +++++-- .../__tests__/safeWriteJson.lockKey.spec.ts | 35 +++++++++- src/utils/__tests__/safeWriteJson.test.ts | 53 ++++++++++++++ src/utils/jsonFileRecovery.ts | 33 +++++++-- src/utils/safeWriteJson.ts | 41 ++++++++--- 9 files changed, 323 insertions(+), 47 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts b/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts index 19a95611a5..e9158d2b68 100644 --- a/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "child_process" +import { spawnSync } from "child_process" import * as fs from "fs" import * as os from "os" import * as path from "path" @@ -36,9 +36,30 @@ function shortPathOf(directory: string): string | null { return null } try { - const out = execFileSync(SHORT_NAME_PROBE_BIN, ["/c", "for %A in (" + directory + ") do @echo %~sA"], { + // The directory has to reach cmd quoted. cmd's `for ... in (set)` splits an unquoted set + // on spaces, so a directory named "...has a space inside it" becomes six items and cmd + // echoes one line per item, each resolved against cmd's own working directory (measured + // with node 22.23.4: the unquoted form answers six lines for one directory). Quoting the + // item is not enough by itself: quoting argv entries that contain spaces leaves cmd a value + // whose quotes are part of the name, so %~sA hands back the long spelling wrapped in quotes + // and the probe reports no alias at all (measured: three rows skipped, none run). + // windowsVerbatimArguments hands cmd exactly the string written here, which is the only + // measured form that answers a single short path for a directory with a space. It is a + // spawnSync option in the node typings - execFileSync's typed options do not carry it + // (tsc reports TS2769 there), so the probe runs through the API that documents the flag. + // Both measure the same answer in all four quoting combinations (measured with node 22.23.4 + // on win32), so the switch changes the typing, not the behaviour. + const probe = spawnSync(SHORT_NAME_PROBE_BIN, ["/c", 'for %A in ("' + directory + '") do @echo %~sA'], { encoding: "utf8", - }).trim() + windowsVerbatimArguments: true, + }) + // The typings say `error: Error | null`; spawnSync actually leaves it undefined on a + // successful run, and a `!== null` test would reject every healthy probe - which is how + // the whole suite ends up silently skipped rather than run. + if (probe.error || probe.status !== 0 || probe.stdout === null) { + return null + } + const out = probe.stdout.trim() return out.includes(SHORT_NAME_MARKER) ? out : null } catch { return null @@ -61,8 +82,19 @@ function probeGeneratesEightThreeNames(): boolean { } catch { return false } finally { + // Cleanup must not decide the answer. `force` swallows a missing directory but not the + // EPERM/EBUSY Windows reports while a handle is still being released, and a throw from this + // finally block escapes the function at module import time - the failed-suite-with-no-skip- + // that-vitest-can-rescue case this probe exists to avoid. if (probeBase !== undefined) { - fs.rmSync(probeBase, { recursive: true, force: true }) + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.rmSync(probeBase, { recursive: true, force: true }) + break + } catch { + if (attempt === 1) break + } + } } } } @@ -120,6 +152,32 @@ describe("safeWriteText staging guard against a second spelling of the target di }, ) + // The probe is the harness for both rows below, and its own answer is what breaks when the + // directory is quoted badly: a space in a directory name is not exotic on Windows - every + // profile path under "Documents and Settings" has one - and an unquoted for-set turns one + // directory into one line per word of its name. + it.runIf(IS_WINDOWS && EIGHT_THREE_NAMES_ARE_GENERATED)( + "answers one short path for a directory whose name contains a space", + () => { + const spacedDir = path.join(tempDir, "SPACE CONTAINING TARGET DIRECTORY") + fs.mkdirSync(spacedDir) + const short = shortPathOf(spacedDir) + expect(short).not.toBeNull() + if (short === null) { + return + } + // One answer, not one answer per word of the name. + expect(short.split(/\r?\n/)).toHaveLength(1) + // And the answer is a directory that exists, not a token of the name resolved + // against the probe's own working directory. + expect(fs.existsSync(short)).toBe(true) + expect(fs.statSync(short).isDirectory()).toBe(true) + const viaShort = fs.statSync(short, { bigint: true }) + const viaLong = fs.statSync(spacedDir, { bigint: true }) + expect([String(viaShort.dev), String(viaShort.ino)]).toEqual([String(viaLong.dev), String(viaLong.ino)]) + }, + ) + it.runIf(EIGHT_THREE_NAMES_ARE_GENERATED)( "still rejects a staging file spelled through another directory 8.3 alias", async () => { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index d5f845d4f3..9615af6f25 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1592,9 +1592,16 @@ 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)) - // 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") + // Two reads happen, in order: the link answers with its referent, and the referent - + // absent while the peer writer is mid-commit - answers ENOENT (measured with node + // 22.23.4 on win32), which is what ends the walk. Answered by call order so the + // mock's return type still matches fs.promises.readlink on every platform. + let linkReads = 0 + vi.mocked(fs.readlink).mockImplementation(async () => { + linkReads++ + if (linkReads === 1) return "referent.json" + throw enoent + }) // Mid-commit a peer writer renames the referent away and back, so the key // must still be computable while the link dangles. @@ -1603,19 +1610,51 @@ describe("resolveLockKey", () => { ) }) - it("terminates on a two-link cycle instead of walking forever", async () => { + it("rejects instead of naming an arbitrary link when the bounded walk exhausts", 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. + // Every readlink answers with the same link, so an unbounded walk would never + // end. Past the bound the chain names no file: the behaviour this row replaces + // handed the caller whichever link the walk stopped on, so a writer starting from + // the other end of the same cycle keyed the very same file with a different lock. 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")), - ) + await expect(resolveLockKey("/tmp/linkdir/a.json")).rejects.toThrow("the symlink chain is deeper than 8 links") expect(fs.readlink).toHaveBeenCalledTimes(8) }) + + it("propagates a readlink failure that is not a not-a-link or absent answer", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + const eio = Object.assign(new Error("EIO: i/o error, readlink"), { code: "EIO" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + // An unreadable link says nothing about where the file is. Answering with the + // alias would let this writer hold the alias's lock while a writer that reached + // the referent holds the referent's lock: two locks, one file, and the merged + // update that gets lost. + vi.mocked(fs.readlink).mockRejectedValueOnce(eio) + + await expect(resolveLockKey("/tmp/linkdir/file.json")).rejects.toBe(eio) + expect(fs.readlink).toHaveBeenCalledTimes(1) + }) + + it("ends the walk on the two answers that mean not-a-link or absent", 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)) + const reachedKey = path.join(path.dirname("/tmp/linkdir/file.json"), "file.json") + // Control for the row above. A regular file answers readlink with EINVAL - measured + // with node 22.23.4 on win32 - and an absent path answers with ENOENT; the walk has + // to stop on both and key the path it reached, or every plain write to a normal + // file would fail out of the lock-key step. + vi.mocked(fs.readlink).mockRejectedValueOnce( + Object.assign(new Error("EINVAL: invalid argument, readlink"), { code: "EINVAL" }), + ) + await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe(reachedKey) + vi.mocked(fs.readlink).mockRejectedValueOnce(enoent) + await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe(reachedKey) + }) }) describe("caller-supplied staging path", () => { @@ -1639,10 +1678,16 @@ describe("caller-supplied staging path", () => { 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. + // Renaming a link over the target publishes whatever the link points at. The name + // is convention-conforming on purpose: with a name like "x.tmp" the name guard + // rejects the call first, so this row would still pass if the link check were + // deleted, and it would be pinning the name rule instead of the link rule. await expect( - safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/x.tmp", platform: "linux" }), - ).rejects.toThrow(StagingPathError) + safeWriteText(targetPath, "data", { + tempPath: "/tmp/test-dir/.new_target.txt_1.tmp", + platform: "linux", + }), + ).rejects.toThrow("Staging file must be a regular file, not a symlink") expect(fsSync.openSync).not.toHaveBeenCalled() expect(fs.rename).not.toHaveBeenCalled() }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 3846158a7e..72bf9c78fd 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -293,8 +293,10 @@ async function canonicalDirKey(absoluteFilePath: string): Promise { * tolerates a dangling link, because the lock key has to be computable while a * 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. + * The walk is bounded, and a chain the bound cannot resolve rejects rather than naming + * an arbitrary intermediate link: two writers that enter the same chain from different + * ends would otherwise key one file with two different locks. Every key it does return + * is canonicalized through canonicalDirKey. */ export async function resolveLockKey(absoluteFilePath: string): Promise { try { @@ -307,16 +309,29 @@ export async function resolveLockKey(absoluteFilePath: string): Promise if (errorCode(error) !== "ENOENT") { throw error } - // 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. + // A real readlink reports "this is not a link" by throwing: EINVAL on the + // platforms this file runs on (measured with node 22.23.4 on win32, and EINVAL + // on linux), and ENOENT when the path itself is gone. Those two answers end the + // walk. Any other failure - EACCES, EIO, ELOOP - says nothing about whether this + // path is a link, so it propagates: keying this writer to the alias while a + // writer that reached the referent keys the referent is the two-locks-for-one-file + // loss this function exists to prevent, and guessing it is how that loss arrives. + const linkWalkLimit = 8 let key = absoluteFilePath - for (let depth = 0; depth < 8; depth++) { - const target = await fs.readlink(key).catch(() => undefined) + for (let depth = 0; depth < linkWalkLimit; depth++) { + const target = await fs.readlink(key).catch((linkError: unknown) => { + const code = errorCode(linkError) + if (code === "ENOENT" || code === "EINVAL") return undefined + throw linkError + }) if (target === undefined) return await canonicalDirKey(key) key = await canonicalDirKey(path.resolve(path.dirname(key), target)) } - return await canonicalDirKey(key) + // The bound only exists to terminate a cycle. Past it the chain names no file, so + // the caller is told instead of handed whichever link the walk stopped on. + throw new Error( + `safeWriteText: could not resolve a lock key for ${absoluteFilePath}: the symlink chain is deeper than ${linkWalkLimit} links`, + ) } } diff --git a/src/utils/__tests__/jsonFileRecovery.test.ts b/src/utils/__tests__/jsonFileRecovery.test.ts index 6e67938010..a1df775188 100644 --- a/src/utils/__tests__/jsonFileRecovery.test.ts +++ b/src/utils/__tests__/jsonFileRecovery.test.ts @@ -152,6 +152,23 @@ describe("replacement leftover name patterns", () => { expect(JSON.parse(await fs.readFile(sibling, "utf8"))).toEqual({ sibling: true }) }) + // The boundary a prefix check alone misses: a sibling whose basename *starts with* this + // basename plus an underscore. Its staging name carries this file's prefix, and its writer + // holds only its own lock, so collecting that name would unlink a live writer's staging file + // and fail its commit rename with ENOENT. + it("leaves the staging file of a sibling whose name extends this basename", async () => { + const sibling = path.join(dir, "a.json_extra.json") + await fs.writeFile(sibling, JSON.stringify({ sibling: true })) + const mine = path.join(dir, ".new_a.json_1_mine.tmp") + const theirs = path.join(dir, ".new_a.json_extra_2_theirs.tmp") + await fs.writeFile(mine, "stale") + await fs.writeFile(theirs, "in flight") + await expect(recoverMissingJsonFile(target)).resolves.toBe(true) + await expect(fs.access(mine)).rejects.toMatchObject({ code: "ENOENT" }) + expect(await fs.readFile(theirs, "utf8")).toBe("in flight") + expect(await fs.readFile(sibling, "utf8")).toBe(JSON.stringify({ sibling: true })) + }) + // The negative boundary: safeWriteText accepts any basename matching .new_*.tmp, so // an unscoped name proves nothing about which target staged it, and recovery must // not treat it as this target's leftover. diff --git a/src/utils/__tests__/safeWriteJson.interruption.test.ts b/src/utils/__tests__/safeWriteJson.interruption.test.ts index 5840f9bea6..bad810e552 100644 --- a/src/utils/__tests__/safeWriteJson.interruption.test.ts +++ b/src/utils/__tests__/safeWriteJson.interruption.test.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawn, type ChildProcess } from "child_process" +import { spawn, spawnSync, type ChildProcess } from "child_process" import { createRequire } from "module" import { fileURLToPath } from "url" import * as fs from "fs/promises" @@ -398,9 +398,22 @@ describe("safeWriteJson subprocess interruption fault injection", () => { return null } try { - const out = execFileSync("cmd.exe", ["/c", "for %A in (" + directory + ") do @echo %~sA"], { + // Quoted, and verbatim: cmd splits an unquoted for-set on spaces, and a quoted argv + // entry alone leaves cmd a value whose quotes are part of the name (see the same probe + // in safeWriteText.alias.spec.ts, where both forms are measured). spawnSync is the API + // whose typings carry windowsVerbatimArguments - execFileSync's do not - and both + // measure the same answer, so the choice is about the typing only. + const probe = spawnSync("cmd.exe", ["/c", 'for %A in ("' + directory + '") do @echo %~sA'], { encoding: "utf8", - }).trim() + windowsVerbatimArguments: true, + }) + // The typings say `error: Error | null`; spawnSync leaves it undefined on a successful run, + // so a `!== null` test would reject every healthy probe and make the alias rows below + // return early instead of asserting. + if (probe.error || probe.status !== 0 || probe.stdout === null) { + return null + } + const out = probe.stdout.trim() return out.includes("~1") ? out : null } catch { return null @@ -408,7 +421,11 @@ describe("safeWriteJson subprocess interruption fault injection", () => { } it("recognises the commit rename through a short-name alias, and refuses another directory", async () => { - const base = await fs.mkdtemp(path.join(os.tmpdir(), "interruption-matcher-")) + // Inside tempDir on purpose: this test returns early on a box with no short-name alias and + // stops at an assertion failure more often than it reaches its own cleanup, and a directory + // under os.tmpdir() survives both. The describe's afterEach removes tempDir, so creating the + // matcher's scratch tree inside it cleans up on every exit. + const base = await fs.mkdtemp(path.join(tempDir, "interruption-matcher-")) const longDir = path.join(base, "LONGMATCHERDIRECTORYNAME") const otherDir = path.join(base, "OTHERMATCHERDIRECTORYNAME") await fs.mkdir(longDir, { recursive: true }) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 20676c159b..2c5189b24c 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -24,6 +24,14 @@ const mockedAcquireFileLock = vi.mocked(acquireFileLock) const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) +// A real readlink answers with a code. ENOENT is the answer for a path that is not there - +// which is the state both peer-commit rows below describe, because the peer writer has renamed +// the referent away - and EINVAL is the answer for a path that exists and is not a link +// (measured with node 22.23.4 on win32). resolveLockKey ends its walk on either, and propagates +// anything else: a rejection that names no code says nothing about whether the path is a link, +// and keying the writer to the alias on that answer is how two writers end up holding two locks +// for one file. + // 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[] = [] @@ -85,7 +93,7 @@ describe("safeWriteJson lock key under a peer commit", () => { return symlinkStat(target) }) mockedReadlink.mockImplementation(async (target) => - target === currentLink ? referent : Promise.reject(new Error("not a link")), + target === currentLink ? referent : Promise.reject(enoent), ) mockedAcquireFileLock.mockImplementation(async () => { order.push("lock") @@ -135,7 +143,7 @@ describe("safeWriteJson lock key under a peer commit", () => { return symlinkStat(target) }) mockedReadlink.mockImplementation(async (target) => - target === currentLink ? referent : Promise.reject(new Error("not a link")), + target === currentLink ? referent : Promise.reject(enoent), ) mockedAcquireFileLock.mockImplementation(async () => { order.push("lock") @@ -187,6 +195,29 @@ describe("safeWriteJson lock key under a peer commit", () => { }) }) +it("keeps a readlink failure that names no code from keying the writer to the alias", async () => { + // The peer-commit rows above answer with the code a real readlink uses. This row covers the + // other answer: a rejection that names nothing. Keying the writer to the alias path would let + // it hold the alias's lock while a writer that reached the referent held the referent's lock, + // and the safeWriteJson flow must not reach the lock at all when the key is unknown. + const dir = await makeDir("lockkey-") + currentLink = path.join(dir, "link.json") + const unanswering = new Error("readlink failed without naming a code") + mockedRealpath.mockImplementation(async () => { + throw enoent + }) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedReadlink.mockImplementation(async () => Promise.reject(unanswering)) + let locked = false + mockedAcquireFileLock.mockImplementation(async () => { + locked = true + return async () => {} + }) + + await expect(safeWriteJson(currentLink, { id: "task-1" })).rejects.toBe(unanswering) + expect(locked).toBe(false) +}) + 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 diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index fc8381206c..f523953783 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -1005,4 +1005,57 @@ describe("safeWriteJson", () => { const stagingUnlinks = vi.mocked(fs.unlink).mock.calls.filter((call) => /\.new_.*\.tmp$/.test(String(call[0]))) expect(stagingUnlinks).toHaveLength(0) }) + + it("does not restore the fallback backup over a commit that landed", async () => { + const fsSyncMod = await import("fs") + const realFsync = fsSyncMod.fsyncSync + const fsyncFailure = new Error("EIO directory fsync") + const newData = { published: "new content" } + let renameCallCount = 0 + let committed = false + const actualRename = fsPromisesActuals.rename! + // The EPERM fallback is the only path that takes a backup at all: the first commit + // rename fails with the Windows-style EPERM, the fallback moves the target to its + // backup and renames the replacement in. That second commit succeeds - it is the + // rename onto the target below - and only then does the parent-directory fsync fail. + vi.mocked(fs.rename).mockImplementation((async (from: string, to: string) => { + renameCallCount++ + if (renameCallCount === 1) { + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + } + await actualRename(from, to) + if (path.resolve(to) === path.resolve(currentTestFilePath)) committed = true + }) as typeof fs.rename) + vi.spyOn(process, "platform", "get").mockReturnValue("linux") + vi.spyOn(fsSyncMod, "fsyncSync").mockImplementation(((fd: number) => { + if (committed) throw fsyncFailure + return (realFsync as (f: number) => unknown)(fd) + }) as never) + vi.mocked(fs.unlink).mockClear() + + const failure: unknown = await safeWriteJson(currentTestFilePath, newData).then( + () => null, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(PostCommitDurabilityError) + expect((failure as Error).cause).toBe(fsyncFailure) + + // The replacement is at the target. Renaming the backup back over it - which is what + // the pre-commit rollback does - would put the old bytes where the rethrown error says + // the new content is. + expect(await readFileContent(currentTestFilePath)).toEqual(newData) + + // Nothing moved the backup back, and the stale backup is gone rather than left beside + // the published file. + const rollbackRenames = vi + .mocked(fs.rename) + .mock.calls.filter( + (call) => + /\.bak_/.test(String(call[0])) && + path.resolve(String(call[1])) === path.resolve(currentTestFilePath), + ) + expect(rollbackRenames).toHaveLength(0) + const backupLeftovers = (await fs.readdir(tempDir)).filter((entry) => entry.includes(".bak_")) + expect(backupLeftovers).toEqual([]) + }) }) diff --git a/src/utils/jsonFileRecovery.ts b/src/utils/jsonFileRecovery.ts index 8b55170573..76f646d9ca 100644 --- a/src/utils/jsonFileRecovery.ts +++ b/src/utils/jsonFileRecovery.ts @@ -19,13 +19,34 @@ function getStagedReplacementPrefix(absoluteFilePath: string): string { return `.new_${path.basename(absoluteFilePath)}_` } +/** + * The tail every writer of this file produces after its prefix: `__.tmp` + * (see safeWriteJson's createStagingFilePath and createBackupFilePath). The random token is + * base36, so it never contains an underscore, and the timestamp is all digits. + */ +const OWNED_LEFTOVER_TAIL = /^\d+_[^_]+\.tmp$/ + +/** + * Whether `entry` is a leftover this target's own writer staged. + * + * Matching the prefix alone is not scoping. A basename that starts with this basename plus an + * underscore - `a.json_extra.json` beside `a.json` - produces `.new_a.json_extra__.tmp`, + * which starts with this file's `.new_a.json_` prefix. Its writer holds only the sibling's lock, + * so a recovery of `a.json` that collected that name would unlink a staging file a live writer is + * about to publish, and that write then fails with ENOENT before it can commit. Requiring the tail + * a writer of this file actually produces keeps the pattern scoped to the target. + */ function isReplacementLeftover(absoluteFilePath: string, entry: string): boolean { - return ( - entry.endsWith(".tmp") && - (entry.startsWith(getTempFilePrefix(absoluteFilePath, "new")) || - entry.startsWith(getTempFilePrefix(absoluteFilePath, "bak")) || - entry.startsWith(getStagedReplacementPrefix(absoluteFilePath))) - ) + for (const prefix of [ + getTempFilePrefix(absoluteFilePath, "new"), + getTempFilePrefix(absoluteFilePath, "bak"), + getStagedReplacementPrefix(absoluteFilePath), + ]) { + if (entry.startsWith(prefix) && OWNED_LEFTOVER_TAIL.test(entry.slice(prefix.length))) { + return true + } + } + return false } /** diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index f9e546a7fd..2e89533c88 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -249,18 +249,37 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit actualTempNewFilePath = null } - // If the EPERM fallback had moved the committed file to the backup path, restore it - // so the target keeps its previous content. A failed restore leaves the backup as - // the only committed copy - it is preserved for recovery by a later writer rather - // than unlinked, and the error from the failed replacement is the one rethrown. + // If the EPERM fallback had moved the committed file to the backup path, decide by + // whether the second commit landed. A PostCommitDurabilityError is raised only after + // the commit rename succeeded, so the new content is already at the target and the + // backup holds the content the target no longer has: renaming it back would put the + // old bytes over the published ones while the rethrown error tells the caller the new + // content is at the target. It is dropped instead, and a failed drop leaves it as a + // leftover the next writer's recovery pass collects. + // A failure before the commit is the other case: the target still holds the content + // the fallback moved aside, so the backup goes back and a failed restore leaves it + // as the only committed copy for a later recovery rather than unlinked. if (actualTempBackupFilePath) { - try { - await fs.rename(actualTempBackupFilePath, resolvedTargetPath ?? absoluteFilePath) - } catch (rollbackError) { - console.error( - `Failed to restore backup ${actualTempBackupFilePath} to ${resolvedTargetPath ?? absoluteFilePath}:`, - rollbackError, - ) + if (originalError instanceof PostCommitDurabilityError) { + try { + await fs.unlink(actualTempBackupFilePath) + } catch (backupCleanupError: unknown) { + if (getErrorCode(backupCleanupError) !== "ENOENT") { + console.error( + `Published ${resolvedTargetPath ?? absoluteFilePath} but failed to delete the stale EPERM-fallback backup ${actualTempBackupFilePath}:`, + backupCleanupError, + ) + } + } + } else { + try { + await fs.rename(actualTempBackupFilePath, resolvedTargetPath ?? absoluteFilePath) + } catch (rollbackError) { + console.error( + `Failed to restore backup ${actualTempBackupFilePath} to ${resolvedTargetPath ?? absoluteFilePath}:`, + rollbackError, + ) + } } actualTempBackupFilePath = null } From d949cab0da20141a25406a16c6040b9744b63145 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:53:18 +0800 Subject: [PATCH 31/38] fix(jsonFileRecovery): scope leftover tails per prefix so a timestamp-only backup is still recoverable The platform-unit-test (ubuntu-latest) arm went red at 9f150f5b on my own change. Its annotation reads "command (/home/runner/work/Zoo-Code/Zoo-Code/src) .../pnpm run test:coverage:core exited (1)" (check run 128966094221); the platform-unit-test (windows-latest) run 128966094415 was cancelled by fail-fast and carries no verdict of its own. Reproducing the expanded script locally (vitest run --config vitest.core.config.ts --coverage) reproduced it deterministically at that head: exit 1, "Tests 1 failed | 4233 passed | 9 skipped", failing row core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts > "preserves a recovered record instead of overwriting it during legacy migration", which plants .history_item.json.bak_1.tmp and expects migration to recover it. The same spec passes at the parent d29b7a34 (exit 0, 12 passed), and reverting only src/utils/jsonFileRecovery.ts to the parent version at this head makes it pass too (12 passed), so the cause is mine: the tail I required for every prefix, ^\d+_[^_]+\.tmp$, dropped the timestamp-only backup names that predate the random token. Scope the tail by what each prefix already pins. The dot-anchored prefixes ..new_ and ..bak_ carry the basename behind a literal dot, so a timestamp alone identifies the name and the token stays optional. The underscore-anchored staged prefix .new__ is not scoped by its prefix alone - a.json_extra.json beside a.json stages .new_a.json_extra__.tmp, which starts with .new_a.json_ and belongs to a writer holding only the sibling's lock - so only the full _.tmp tail attributes a staged-looking name there. Proof rows: jsonFileRecovery.test.ts adds "collects a timestamp-only backup name under the dot-anchored prefix" (fails with a uniform strict tail: 1 failed / 17 passed) and "does not collect a staged-looking name that lacks the token" (fails with a uniform relaxed tail: 1 failed / 17 passed). The coverage command on the fixed tree: run 3 exit 0, "196 passed (196)", "4234 passed | 9 skipped", no failing file; run 4 exit 1 with the same 196 files and 4234 tests passing and 1 unhandled EnvironmentTeardownError ("[vitest-worker]: Closing rpc while onUserConsoleLog was pending", originating in core/webview/__tests__/ClineProvider.spec.ts). That teardown error is the shard's own flake, independent of this fix: 12 occurrences at the previous head, 0 in the second run there, 0 in run 3 and 1 in run 4 here, and it flips the exit code with zero failing tests. --- src/utils/__tests__/jsonFileRecovery.test.ts | 24 ++++++++++ src/utils/jsonFileRecovery.ts | 47 +++++++++++--------- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/utils/__tests__/jsonFileRecovery.test.ts b/src/utils/__tests__/jsonFileRecovery.test.ts index a1df775188..b351f68599 100644 --- a/src/utils/__tests__/jsonFileRecovery.test.ts +++ b/src/utils/__tests__/jsonFileRecovery.test.ts @@ -136,6 +136,30 @@ describe("replacement leftover name patterns", () => { }, ) + // The tail rule is deliberately not uniform across the prefixes, and core/task-persistence/ + // __tests__/TaskHistoryStore.realConcurrency.spec.ts is the reason it may not be: that spec plants + // `.history_item.json.bak_1.tmp`, a backup from before writers appended a random token, and + // migration must still recover it. The dot-anchored prefix already pins this file's basename + // through the literal dot before the kind, so the token stays optional there. + it("collects a timestamp-only backup name under the dot-anchored prefix", async () => { + const leftover = path.join(dir, ".a.json.bak_1.tmp") + await fs.writeFile(leftover, "stale") + await expect(recoverMissingJsonFile(target)).resolves.toBe(true) + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + await expect(fs.access(leftover)).rejects.toMatchObject({ code: "ENOENT" }) + }) + + // The other half of the same rule: on the underscore-anchored staged prefix the token is the only + // thing that attributes a staged-looking name to this target, so the very same tail shape there + // is somebody else's in-flight file and must survive. + it("does not collect a staged-looking name that lacks the token", async () => { + const unscoped = path.join(dir, ".new_a.json_1.tmp") + await fs.writeFile(unscoped, "someone else's staging file") + await expect(recoverMissingJsonFile(target)).resolves.toBe(false) + expect(await fs.readFile(unscoped, "utf8")).toBe("someone else's staging file") + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + }) + // A shared directory is the failure mode the target-derived prefix exists to // prevent: a live writer of b.json holds only b.json's lock, so recovery of a.json // must not unlink the staging file that writer is about to rename onto b.json. diff --git a/src/utils/jsonFileRecovery.ts b/src/utils/jsonFileRecovery.ts index 76f646d9ca..9560d2d0cb 100644 --- a/src/utils/jsonFileRecovery.ts +++ b/src/utils/jsonFileRecovery.ts @@ -20,29 +20,36 @@ function getStagedReplacementPrefix(absoluteFilePath: string): string { } /** - * The tail every writer of this file produces after its prefix: `__.tmp` - * (see safeWriteJson's createStagingFilePath and createBackupFilePath). The random token is - * base36, so it never contains an underscore, and the timestamp is all digits. - */ -const OWNED_LEFTOVER_TAIL = /^\d+_[^_]+\.tmp$/ - -/** - * Whether `entry` is a leftover this target's own writer staged. + * The tail that marks a leftover as this target's own, and the three prefixes do not scope alike. * - * Matching the prefix alone is not scoping. A basename that starts with this basename plus an - * underscore - `a.json_extra.json` beside `a.json` - produces `.new_a.json_extra__.tmp`, - * which starts with this file's `.new_a.json_` prefix. Its writer holds only the sibling's lock, - * so a recovery of `a.json` that collected that name would unlink a staging file a live writer is - * about to publish, and that write then fails with ENOENT before it can commit. Requiring the tail - * a writer of this file actually produces keeps the pattern scoped to the target. + * The dot-anchored prefixes `..new_` and `..bak_` already pin this file's + * basename through the literal dot before the kind, so a timestamp alone identifies the name. The + * token is optional there because writers appended it later: `.history_item.json.bak_1.tmp`, which + * core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts still plants and + * migration must still recover, is a backup of this target. Current writers append + * `__.tmp` (see safeWriteJson's createStagingFilePath and createBackupFilePath); + * the base36 token never contains an underscore and the timestamp is all digits. + * + * The underscore-anchored staged prefix `.new__` is not scoped by its prefix alone. A + * basename that starts with this basename plus an underscore - `a.json_extra.json` beside `a.json` - + * produces `.new_a.json_extra__.tmp`, which starts with this file's `.new_a.json_` prefix. + * That writer holds only the sibling's lock, so collecting the name would unlink a staging file a + * live writer is about to publish, and that write then fails with ENOENT before it can commit. Only + * the full `_.tmp` tail attributes a staged-looking name, so without the token it is not + * this target's leftover. */ +const ANCHORED_LEFTOVER_TAIL = /^\d+(_[^_]+)?\.tmp$/ +const STAGED_LEFTOVER_TAIL = /^\d+_[^_]+\.tmp$/ + +/** Whether `entry` is a leftover this target's own writer staged. */ function isReplacementLeftover(absoluteFilePath: string, entry: string): boolean { - for (const prefix of [ - getTempFilePrefix(absoluteFilePath, "new"), - getTempFilePrefix(absoluteFilePath, "bak"), - getStagedReplacementPrefix(absoluteFilePath), - ]) { - if (entry.startsWith(prefix) && OWNED_LEFTOVER_TAIL.test(entry.slice(prefix.length))) { + const patterns: [string, RegExp][] = [ + [getTempFilePrefix(absoluteFilePath, "new"), ANCHORED_LEFTOVER_TAIL], + [getTempFilePrefix(absoluteFilePath, "bak"), ANCHORED_LEFTOVER_TAIL], + [getStagedReplacementPrefix(absoluteFilePath), STAGED_LEFTOVER_TAIL], + ] + for (const [prefix, tail] of patterns) { + if (entry.startsWith(prefix) && tail.test(entry.slice(prefix.length))) { return true } } From 4b85be99d7d6c516d7197d4dabdc1a813ae9565e Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:19:55 +0800 Subject: [PATCH 32/38] fix(file-safety): anchor the post-commit injection on the commit rename, and key the link's real parent The platform-unit-test (windows-latest) arm went red at d949cab0 on a row I added. Its annotation reads "command (D:\a\Zoo-Code\Zoo-Code\src) ...pnpm.CMD run test:misc exited (1)" (check run 128970617896) and the job prints one failing row: utils/__tests__/safeWriteJson.test.ts > "does not restore the fallback backup over a commit that landed", with "expected Error: EPERM: operation not permitted, fs... to be Error: EIO directory fsync" at safeWriteJson.test.ts:1041 and the lane summary "Test Files 1 failed | 118 passed | 1 skipped (120)", "Tests 1 failed | 2115 passed | 14 skipped (2130)". No test failed on the ubuntu arm of this head: it was cancelled as fail-fast collateral ("The strategy configuration was canceled because \"unit-test.windows-latest_windows-l\" failed"), so windows carries the only verdict. The injection decided "the commit rename landed" by comparing the rename destination with the path the test built. safeWriteText commits onto its re-resolved publish target, and when the temp directory is reached through a link - which is how the windows-latest runner reports its temp path - that destination is the resolved directory, not the literal spelling. The flag never fired, so fsyncSync's injected failure never ran, the real parent-directory fsync ran instead, and on a read-only handle that is EPERM on Windows - the same reason safeWriteText opens its backup with "r+". Recognise the commit rename by what it lands on (the target's basename, which neither the staging name nor the backup name carries) instead of by path identity. Measured reproduction, same tree, same interpreter (node 22.23.4), only the temp path changed: with the temp directory reached through a junction the row fails exactly as CI reports - exit 1, "Tests 1 failed | 44 passed | 1 skipped (46)" - and passes after this change ("45 passed | 1 skipped"); on a plain temp path it passed both before and after, which is why my local gate set was green. The misc lane on the fixed tree: exit 0, "118 passed | 1 skipped (119)", "2112 passed | 14 skipped (2126)". Three rows carry the lock-key family that the sibling unit's review raised on the same primitive: keys a relative referent through the link's real parent (under a symlinked directory `..` names the referent's parent, so the lexical join keyed a different file and the two writers held two locks); refuses a lock key when an ancestor exists but cannot be canonicalized (a swallowed realpath failure handed back the literal spelling, a key no other writer uses); keys the same file the same way before and after its parent directory appears (missing components are walked over and re-joined onto the nearest ancestor that exists, so the writer that arrives before the mkdir and the one that arrives after take one lock). Without the two canonicalDirKey/resolveLockKey changes those three rows fail (3 failed | 6 passed); with them the suite is 9 passed. --- src/services/file-safety/safeWriteText.ts | 42 ++++++- .../__tests__/safeWriteJson.lockKey.spec.ts | 106 ++++++++++++++++++ src/utils/__tests__/safeWriteJson.test.ts | 26 ++++- 3 files changed, 163 insertions(+), 11 deletions(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 72bf9c78fd..7dc723d4ef 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -279,12 +279,36 @@ function errorCode(error: unknown): string | undefined { * 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. + * Components that are not there yet are walked over and re-joined onto the nearest + * ancestor that is: the writer that arrives before this file's parent directory is + * created has to take the lock of the writer that arrives after it. Only a missing + * component may be walked over. An ancestor that exists but cannot be canonicalized + * (ENOTDIR under a regular-file component, EACCES, EIO, ELOOP) means the canonical + * key is unknown, and handing back the literal spelling would key this writer apart + * from every writer that did canonicalize the same file. */ - 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)) + const basename = path.basename(absoluteFilePath) + const missing: string[] = [] + let cursor = path.dirname(absoluteFilePath) + for (;;) { + try { + const canonicalDir = await fs.realpath(cursor) + return path.join(canonicalDir, ...missing.reverse(), basename) + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") { + throw error + } + const parent = path.dirname(cursor) + if (parent === cursor) { + // The root of this path was reached with components still missing: no ancestor + // is left to canonicalize, so this spelling is the only answer available. + return path.join(cursor, ...missing.reverse(), basename) + } + missing.push(path.basename(cursor)) + cursor = parent + } + } } /** @@ -325,7 +349,15 @@ export async function resolveLockKey(absoluteFilePath: string): Promise throw linkError }) if (target === undefined) return await canonicalDirKey(key) - key = await canonicalDirKey(path.resolve(path.dirname(key), target)) + // A relative referent is relative to the link's REAL parent, not to the spelling the + // caller arrived with: under a symlinked directory `..` names the referent's parent, + // so a lexical join keys a different file and the writer through the alias stops + // excluding the writer through the referent. When the link's own parent cannot be + // canonicalized - a dangling chain - the lexical join is the only answer available. + const referentParent = path.isAbsolute(target) + ? undefined + : await fs.realpath(path.dirname(key)).catch(() => undefined) + key = await canonicalDirKey(path.resolve(referentParent ?? path.dirname(key), target)) } // The bound only exists to terminate a cycle. Past it the chain names no file, so // the caller is told instead of handed whichever link the walk stopped on. diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 2c5189b24c..83e1625c5e 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -193,6 +193,112 @@ describe("safeWriteJson lock key under a peer commit", () => { }) await expect(resolveLockKey(file)).rejects.toBe(eacces) }) + + it("keys a relative referent through the link's real parent, not the spelled one", async () => { + // The link lives under a directory that is itself a link and its referent is relative. + // `..` names the referent's parent, so joining the referent onto the spelling the caller + // arrived with keys dir/aliasdir/other - a different file from the one a writer that + // reached the referent through dir/realdir/elsewhere keys, and those two writers then + // hold two locks for one file. + const dir = await makeDir("lockkey-") + const spelledParent = path.join(dir, "aliasdir", "notes") + const realParent = path.join(dir, "realdir", "elsewhere") + const link = path.join(spelledParent, "link.json") + currentLink = link + const referent = "../other/x.json" + mockedRealpath.mockImplementation(async (target) => { + const p = String(target) + if (p === link) { + throw enoent // peer mid-commit: the referent is renamed away from this name + } + if (p === spelledParent) { + return realParent // "notes" is itself a directory link + } + return p + }) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedReadlink.mockImplementation(async (target) => + String(target) === link ? referent : Promise.reject(enoent), + ) + + const key = await resolveLockKey(link) + expect(key).toBe(path.join(dir, "realdir", "other", "x.json")) + }) + + it("refuses a lock key when an ancestor exists but cannot be canonicalized", async () => { + // ENOTDIR is what fs.realpath answers when a component of the path is a regular file: + // the ancestor was inspected and it says this spelling is not the canonical one. A + // swallowed realpath failure handed back the literal path, which is a key no other + // writer uses for this file. + const dir = await makeDir("lockkey-") + const link = path.join(dir, "aliasdir", "link.json") + currentLink = link + const referent = path.join(dir, "package", "sub", "x.json") + const enotdir = Object.assign(new Error("ENOTDIR: not a directory"), { code: "ENOTDIR" }) + mockedRealpath.mockImplementation(async (target) => { + const p = String(target) + if (p === link) { + throw enoent + } + if (p === path.join(dir, "package", "sub")) { + throw enotdir // "package" is a file, so this chain is not a directory + } + return p + }) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedReadlink.mockImplementation(async (target) => + String(target) === link ? referent : Promise.reject(enoent), + ) + + await expect(resolveLockKey(link)).rejects.toBe(enotdir) + }) + + it("keys the same file the same way before and after its parent directory appears", async () => { + // A missing parent directory is walked over and re-joined onto the nearest ancestor that + // exists, because the writer that arrives before the mkdir has to take the lock of the + // writer that arrives after it. Under a directory link the literal spelling is not that + // key. The two calls below are the two moments a real mkdir separates. + const realDir = await makeDir("lockkey-") + const aliasDir = path.join(realDir, "alias") + const missingDir = path.join(aliasDir, "state") + const file = path.join(missingDir, "history_item.json") + currentLink = "" + const canonicalParent = path.join(realDir, "referent-of-alias") + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) + + mockedRealpath.mockImplementation(async (target) => { + const p = String(target) + if (p === file || p === missingDir) { + throw enoent // neither the file nor its parent directory is there yet + } + if (p === aliasDir) { + return canonicalParent // "alias" is a directory link + } + return p + }) + const beforeCreation = await resolveLockKey(file) + + // The directory exists by now, which is the only difference the later writer sees. + mockedRealpath.mockImplementation(async (target) => { + const p = String(target) + if (p === file) { + throw enoent + } + if (p === missingDir) { + return path.join(canonicalParent, "state") + } + if (p === aliasDir) { + return canonicalParent + } + return p + }) + const afterCreation = await resolveLockKey(file) + + expect(beforeCreation).toBe(path.join(canonicalParent, "state", "history_item.json")) + expect(afterCreation).toBe(beforeCreation) + }) }) it("keeps a readlink failure that names no code from keying the writer to the alias", async () => { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index f523953783..c9d257c330 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -1011,24 +1011,38 @@ describe("safeWriteJson", () => { const realFsync = fsSyncMod.fsyncSync const fsyncFailure = new Error("EIO directory fsync") const newData = { published: "new content" } - let renameCallCount = 0 - let committed = false + const targetBaseName = path.basename(currentTestFilePath) + let commitAttemptFailed = false + let commitLanded = false const actualRename = fsPromisesActuals.rename! // The EPERM fallback is the only path that takes a backup at all: the first commit // rename fails with the Windows-style EPERM, the fallback moves the target to its // backup and renames the replacement in. That second commit succeeds - it is the // rename onto the target below - and only then does the parent-directory fsync fail. + // The commit rename is recognised by what it lands on rather than by the path this test + // built, because safeWriteText commits onto its re-resolved publish target: when the + // temp directory is reached through a link - which is how the windows-latest runner + // reports its temp path - that destination is the resolved directory, not the literal + // path written here. Path equality then waits for an event that already happened, the + // injection never fires, and the real parent-directory fsync runs instead - on a + // read-only handle that is EPERM on Windows, which is why safeWriteText opens its + // backup "r+". The basename identifies the commit rename here: this write is the only + // activity in this directory, and neither the staging name nor the backup name carries + // it. vi.mocked(fs.rename).mockImplementation((async (from: string, to: string) => { - renameCallCount++ - if (renameCallCount === 1) { + const ontoTarget = path.basename(to) === targetBaseName + if (ontoTarget && !commitAttemptFailed) { + commitAttemptFailed = true throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) } + if (ontoTarget) { + commitLanded = true + } await actualRename(from, to) - if (path.resolve(to) === path.resolve(currentTestFilePath)) committed = true }) as typeof fs.rename) vi.spyOn(process, "platform", "get").mockReturnValue("linux") vi.spyOn(fsSyncMod, "fsyncSync").mockImplementation(((fd: number) => { - if (committed) throw fsyncFailure + if (commitLanded) throw fsyncFailure return (realFsync as (f: number) => unknown)(fd) }) as never) vi.mocked(fs.unlink).mockClear() From 71691733c7a45f7b1dde2a3f27270bf306e77ea5 Mon Sep 17 00:00:00 2001 From: 136036952+easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:25:06 +0800 Subject: [PATCH 33/38] fix(file-safety): stop the lock-key walk at the file it lands on, and always stage a named token resolveLockKey propagated a resolution error only out of the first canonicalisation: a relative referent whose link parent answered EACCES was joined onto the spelled parent, keying this writer where no writer that reached the referent keys - two locks for one file. Only a parent that is not there (ENOENT) may fall back to that join now. The bounded walk also rejected a chain that landed on the file exactly at its bound, so a peer that renames the referent away mid-EPERM-fallback made the write fail where waiting for it would have committed. The bound now checks the terminal path once and still rejects a ninth link; the cycle row's read count moves from 8 to 9 because the ninth read is the terminal check that bounds it. createStagingFilePath and createBackupFilePath took their token from Math.random().toString(36).substring(2), which answers the empty string when Math.random() answers 0 (measured with node 22.23.4): the name ended `__.tmp`, the one tail jsonFileRecovery cannot collect on the underscore-anchored prefix, where the token is the only thing that attributes the name to this target. Both writers guarantee a non-empty token now, and the dot-anchored pattern still accepts a token-less tail because a leftover written before the guarantee is not distinguishable from one written after it. safeWriteText.alias.spec.ts gated every alias row on its own capability probe, so a cmd command that quoted the directory badly answered null for the probe and skipped the file instead of failing it; one row now asks cmd about its own directory. CodeRabbit threads answered here: 4509550084, 4509550081, 4509550093, 4509550078. --- .../__tests__/safeWriteText.alias.spec.ts | 28 +++++++ .../__tests__/safeWriteText.spec.ts | 5 +- src/services/file-safety/safeWriteText.ts | 29 ++++--- src/utils/__tests__/jsonFileRecovery.test.ts | 23 ++++++ .../__tests__/safeWriteJson.lockKey.spec.ts | 79 +++++++++++++++++++ src/utils/__tests__/safeWriteJson.test.ts | 28 +++++++ src/utils/jsonFileRecovery.ts | 7 +- src/utils/safeWriteJson.ts | 4 +- 8 files changed, 189 insertions(+), 14 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts b/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts index e9158d2b68..657e7a22e4 100644 --- a/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.alias.spec.ts @@ -152,6 +152,34 @@ describe("safeWriteText staging guard against a second spelling of the target di }, ) + // The probe above is the harness for every row in this file, so a command that quotes the directory + // badly answers null for the probe directory and it.runIf skips the whole file: the suite cannot tell + // "this volume generates no 8.3 names" from "our cmd command broke". This row asks its own question, + // about its own directory, and skips only on the platform that has no cmd.exe to ask. + it.runIf(IS_WINDOWS)("checks alias discovery without going through the capability flag", () => { + const spacedDir = path.join(tempDir, "SELF CHECKING TARGET DIRECTORY") + fs.mkdirSync(spacedDir) + // The same command shortPathOf runs, with the answer kept instead of collapsed to null. + const query = 'for %A in ("' + spacedDir + '") do @echo %~sA' + const raw = spawnSync(SHORT_NAME_PROBE_BIN, ["/c", query], { + encoding: "utf8", + windowsVerbatimArguments: true, + }) + const answers = raw.stdout.trim().split(/\r?\n/) + // One answer, not one answer per word of the directory name. + expect(answers).toHaveLength(1) + expect(fs.existsSync(answers[0])).toBe(true) + expect(fs.statSync(answers[0]).isDirectory()).toBe(true) + // Where this volume really does hand out an alias - the answer carries the short-name marker - the + // production probe has to answer it for the same directory. A command that lost its quotes answers + // one line per word here and fails this row, where the flag above would only have skipped it. + if (answers[0].includes(SHORT_NAME_MARKER)) { + const short = shortPathOf(spacedDir) + expect(short).not.toBeNull() + expect(short).toBe(answers[0]) + } + }) + // The probe is the harness for both rows below, and its own answer is what breaks when the // directory is quoted badly: a space in a directory name is not exotic on Windows - every // profile path under "Documents and Settings" has one - and an unquoted for-set turns one diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 9615af6f25..c45c494f31 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1621,7 +1621,10 @@ describe("resolveLockKey", () => { vi.mocked(fs.readlink).mockImplementation(async () => "a.json") await expect(resolveLockKey("/tmp/linkdir/a.json")).rejects.toThrow("the symlink chain is deeper than 8 links") - expect(fs.readlink).toHaveBeenCalledTimes(8) + // Eight reads advance the walk and the ninth is the terminal check that finds the link past the + // bound. A cycle still stops on this count; it is the count that bounds the walk, not the count + // it used before the walk could tell a chain ending at the bound from one running past it. + expect(fs.readlink).toHaveBeenCalledTimes(9) }) it("propagates a readlink failure that is not a not-a-link or absent answer", async () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 7dc723d4ef..8e185b28a9 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -342,28 +342,39 @@ export async function resolveLockKey(absoluteFilePath: string): Promise // loss this function exists to prevent, and guessing it is how that loss arrives. const linkWalkLimit = 8 let key = absoluteFilePath - for (let depth = 0; depth < linkWalkLimit; depth++) { + // No condition: the bound check inside the loop is what ends the walk, and an exit-never loop is + // what keeps this function from needing an unreachable return after it. + for (let depth = 0; ; depth++) { const target = await fs.readlink(key).catch((linkError: unknown) => { const code = errorCode(linkError) if (code === "ENOENT" || code === "EINVAL") return undefined throw linkError }) if (target === undefined) return await canonicalDirKey(key) + // At the bound the walk has followed linkWalkLimit links and `key` is what the last of them names. + // A link there is one link past what this bound can name, so it rejects; a non-link there is the + // file the chain ends on, and that is a key even when the chain arrived at it exactly at the bound. + if (depth === linkWalkLimit) { + throw new Error( + `safeWriteText: could not resolve a lock key for ${absoluteFilePath}: the symlink chain is deeper than ${linkWalkLimit} links`, + ) + } // A relative referent is relative to the link's REAL parent, not to the spelling the // caller arrived with: under a symlinked directory `..` names the referent's parent, // so a lexical join keys a different file and the writer through the alias stops - // excluding the writer through the referent. When the link's own parent cannot be - // canonicalized - a dangling chain - the lexical join is the only answer available. + // excluding the writer through the referent. Only a parent that is not there at all - ENOENT, the + // dangling chain - may fall back to that join, because there the join is the only answer + // available. EACCES or EIO says nothing about where this link lives, and joining onto the spelled + // parent keys this writer where no writer that reached the referent keys: the two locks for one + // file that this walk exists to prevent. const referentParent = path.isAbsolute(target) ? undefined - : await fs.realpath(path.dirname(key)).catch(() => undefined) + : await fs.realpath(path.dirname(key)).catch((parentError: unknown) => { + if (errorCode(parentError) !== "ENOENT") throw parentError + return undefined + }) key = await canonicalDirKey(path.resolve(referentParent ?? path.dirname(key), target)) } - // The bound only exists to terminate a cycle. Past it the chain names no file, so - // the caller is told instead of handed whichever link the walk stopped on. - throw new Error( - `safeWriteText: could not resolve a lock key for ${absoluteFilePath}: the symlink chain is deeper than ${linkWalkLimit} links`, - ) } } diff --git a/src/utils/__tests__/jsonFileRecovery.test.ts b/src/utils/__tests__/jsonFileRecovery.test.ts index b351f68599..b53d8392ed 100644 --- a/src/utils/__tests__/jsonFileRecovery.test.ts +++ b/src/utils/__tests__/jsonFileRecovery.test.ts @@ -152,6 +152,29 @@ describe("replacement leftover name patterns", () => { // The other half of the same rule: on the underscore-anchored staged prefix the token is the only // thing that attributes a staged-looking name to this target, so the very same tail shape there // is somebody else's in-flight file and must survive. + // The token a writer can actually be handed: Math.random() === 0 stringifies to "0", so + // substring(2) answers the empty string and the name ends timestamp-underscore-dot-tmp (measured + // with node 22.23.4). Under the dot-anchored prefix that name is still this target's own leftover - + // the literal dot already pins the basename - so recovery has to take it. + it("collects a dot-anchored backup whose random token came out empty", async () => { + const leftover = path.join(dir, ".a.json.bak_1730000000000_.tmp") + await fs.writeFile(leftover, "stale") + await expect(recoverMissingJsonFile(target)).resolves.toBe(true) + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + await expect(fs.access(leftover)).rejects.toMatchObject({ code: "ENOENT" }) + }) + + // And the name the writer produces out of that same answer: safeWriteJson guarantees a token, so the + // staged name carries the empty token's stand-in, and the staged prefix - where the token is the only + // thing that attributes the name - still recognises it. + it("collects a staged leftover whose token is the empty-token stand-in", async () => { + const leftover = path.join(dir, ".new_a.json_1730000000000_0.tmp") + await fs.writeFile(leftover, "stale") + await expect(recoverMissingJsonFile(target)).resolves.toBe(true) + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + await expect(fs.access(leftover)).rejects.toMatchObject({ code: "ENOENT" }) + }) + it("does not collect a staged-looking name that lacks the token", async () => { const unscoped = path.join(dir, ".new_a.json_1.tmp") await fs.writeFile(unscoped, "someone else's staging file") diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts index 83e1625c5e..a7ffb99cfd 100644 --- a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -194,6 +194,85 @@ describe("safeWriteJson lock key under a peer commit", () => { await expect(resolveLockKey(file)).rejects.toBe(eacces) }) + it("propagates a parent-directory error instead of joining the referent onto the spelled parent", async () => { + // The link's parent is itself a directory link and its realpath answers EACCES while the referent + // is relative and dangling. Joining the referent onto the spelled parent names dir/aliasdir/other + // - a key no writer that reached the referent uses - and those two writers then hold two locks for + // one file. Only ENOENT, the chain simply not being there, may fall back to that join. + const dir = await makeDir("lockkey-") + const spelledParent = path.join(dir, "aliasdir", "notes") + const link = path.join(spelledParent, "link.json") + currentLink = link + const referent = "../other/x.json" + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + mockedRealpath.mockImplementation(async (target) => { + const p = String(target) + if (p === link) { + throw enoent // peer mid-commit: the referent is renamed away from this name + } + if (p === spelledParent) { + throw eacces // this directory link cannot be canonicalized right now + } + return p + }) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedReadlink.mockImplementation(async (target) => + String(target) === link ? referent : Promise.reject(enoent), + ) + + await expect(resolveLockKey(link)).rejects.toBe(eacces) + }) + + it("keys the file a chain lands on exactly at its bound", async () => { + // A peer that pulls the referent away mid-EPERM-fallback can leave this writer walking a chain + // it only finishes at the bound. Eight links land on the file, and the bound has to name that + // file - not whichever link the walk happened to stop on - or the write fails where waiting for + // the peer would have committed. + const dir = await makeDir("lockkey-") + const spelling = (n: number) => path.join(dir, "l" + n + ".json") + currentLink = spelling(0) + const hops = new Map() + for (let n = 0; n < 8; n++) { + hops.set(spelling(n), spelling(n + 1)) + } + const einval = Object.assign(new Error("EINVAL: invalid argument"), { code: "EINVAL" }) + mockedRealpath.mockImplementation(async (target) => + String(target) === currentLink ? Promise.reject(enoent) : String(target), + ) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedReadlink.mockImplementation(async (target) => { + const hop = hops.get(String(target)) + // l8 is a regular file: a real readlink answers EINVAL for it (measured with node 22.23.4 + // on win32), which is the answer that ends the walk. + return hop === undefined ? Promise.reject(einval) : hop + }) + + expect(await resolveLockKey(spelling(0))).toBe(spelling(8)) + }) + + it("rejects a chain that runs past its bound instead of naming whichever link it stopped on", async () => { + // The other side of the same bound: nine links name no file this writer can lock, and the + // caller has to be told rather than handed l8, which two writers entering the chain from + // opposite ends would not both name. + const dir = await makeDir("lockkey-") + const spelling = (n: number) => path.join(dir, "l" + n + ".json") + currentLink = spelling(0) + const hops = new Map() + for (let n = 0; n < 9; n++) { + hops.set(spelling(n), spelling(n + 1)) + } + mockedRealpath.mockImplementation(async (target) => + String(target) === currentLink ? Promise.reject(enoent) : String(target), + ) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedReadlink.mockImplementation(async (target) => { + const hop = hops.get(String(target)) + return hop === undefined ? Promise.reject(new Error("readlink answered no code")) : hop + }) + + await expect(resolveLockKey(spelling(0))).rejects.toThrow(/deeper than 8 links/) + }) + it("keys a relative referent through the link's real parent, not the spelled one", async () => { // The link lives under a directory that is itself a link and its referent is relative. // `..` names the referent's parent, so joining the referent onto the spelling the caller diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index c9d257c330..04823340dc 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -962,6 +962,34 @@ describe("safeWriteJson", () => { // ── post-commit durability: the published target must survive (PI regression) ── + it("stages under a token that a zero random still fills", async () => { + // Math.random() === 0 stringifies to "0", so substring(2) answers the empty string and the staging + // name would end _.tmp (measured with node 22.23.4) - the one tail jsonFileRecovery + // cannot collect on the underscore-anchored prefix, where the token is the only thing that + // attributes the name to this target. The other half of the guarantee: a non-zero random still has + // to produce its own token rather than a constant. + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-json-token-")) + const stagedName = async (name: string): Promise => { + const file = path.join(dir, name) + await safeWriteJson(file, { committed: true }) + const calls = vi.mocked(fs.rename).mock.calls + // The commit rename is the last rename of the write: its source is the staging file. + return path.basename(String(calls[calls.length - 1][0])) + } + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0) + const zeroName = await stagedName("zero.json") + randomSpy.mockReturnValue(0.5) + const halfName = await stagedName("half.json") + randomSpy.mockRestore() + await fs.rm(dir, { recursive: true, force: true }) + + expect(zeroName).toMatch(/^\.new_zero\.json_\d+_[^_]+\.tmp$/) + expect(halfName).toMatch(/^\.new_half\.json_\d+_[0-9a-z]+\.tmp$/) + // "i" is what 0.5 answers in this shape: 0.5.toString(36) is "0.i", so substring(2) is "i" + // (measured with node 22.23.4). The staging name has to carry it, not a constant. + expect(halfName).toContain("_i.tmp") + }) + it("keeps the published target with its new content when the post-commit directory fsync fails", async () => { const fsSyncMod = await import("fs") const realFsync = fsSyncMod.fsyncSync diff --git a/src/utils/jsonFileRecovery.ts b/src/utils/jsonFileRecovery.ts index 9560d2d0cb..b843d18216 100644 --- a/src/utils/jsonFileRecovery.ts +++ b/src/utils/jsonFileRecovery.ts @@ -28,7 +28,10 @@ function getStagedReplacementPrefix(absoluteFilePath: string): string { * core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts still plants and * migration must still recover, is a backup of this target. Current writers append * `__.tmp` (see safeWriteJson's createStagingFilePath and createBackupFilePath); - * the base36 token never contains an underscore and the timestamp is all digits. + * the base36 token never contains an underscore, and both writers now guarantee it is not empty: + * Math.random() === 0 stringifies to "0", so substring(2) answers the empty string and the tail + * would be `_.tmp` (measured with node 22.23.4). This pattern still accepts that tail, + * because a leftover written before the guarantee is not distinguishable from one written after it. * * The underscore-anchored staged prefix `.new__` is not scoped by its prefix alone. A * basename that starts with this basename plus an underscore - `a.json_extra.json` beside `a.json` - @@ -38,7 +41,7 @@ function getStagedReplacementPrefix(absoluteFilePath: string): string { * the full `_.tmp` tail attributes a staged-looking name, so without the token it is not * this target's leftover. */ -const ANCHORED_LEFTOVER_TAIL = /^\d+(_[^_]+)?\.tmp$/ +const ANCHORED_LEFTOVER_TAIL = /^\d+(_[^_]*)?\.tmp$/ const STAGED_LEFTOVER_TAIL = /^\d+_[^_]+\.tmp$/ /** Whether `entry` is a leftover this target's own writer staged. */ diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 2e89533c88..632184a3c8 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -68,14 +68,14 @@ export interface SafeWriteJsonOptions { function createStagingFilePath(absoluteFilePath: string): string { return path.join( path.dirname(absoluteFilePath), - `.new_${path.basename(absoluteFilePath)}_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, + `.new_${path.basename(absoluteFilePath)}_${Date.now()}_${Math.random().toString(36).substring(2) || "0"}.tmp`, ) } function createBackupFilePath(absoluteFilePath: string): string { return path.join( path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, + `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2) || "0"}.tmp`, ) } From f2847c4298e4c7927007c4b24fc0621ccf1f2f72 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:07:00 +0800 Subject: [PATCH 34/38] tests: cover the empty-token restore path and the EPERM backup token, and let afterEach clean the token row - jsonFileRecovery.test.ts: with the target missing, the empty-token backup must be what the reader returns, not merely a name the scanner is willing to unlink. - safeWriteJson.test.ts: pin the EPERM fallback's backup name to the same non-empty-token guarantee the staging name has; the mock's rename history survives between tests, so it is cleared before the write. - safeWriteJson.test.ts: the token row writes into the suite's own tempDir, which afterEach removes whatever happens, and restores the Math.random spy in a finally block. Tests only, so the gate's changed-line count is unchanged. Pinned node v22.23.4: both suites pass (68 passed, 1 skipped). Two revert controls discriminate: tightening ANCHORED_LEFTOVER_TAIL fails the two dot-anchored collection rows plus the new restore row; removing the empty-token stand-in from createBackupFilePath fails only the new backup row. --- src/utils/__tests__/jsonFileRecovery.test.ts | 11 +++++ src/utils/__tests__/safeWriteJson.test.ts | 42 ++++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/utils/__tests__/jsonFileRecovery.test.ts b/src/utils/__tests__/jsonFileRecovery.test.ts index b53d8392ed..87f02ec3b4 100644 --- a/src/utils/__tests__/jsonFileRecovery.test.ts +++ b/src/utils/__tests__/jsonFileRecovery.test.ts @@ -164,6 +164,17 @@ describe("replacement leftover name patterns", () => { await expect(fs.access(leftover)).rejects.toMatchObject({ code: "ENOENT" }) }) + // The same name on the reader's restore path, which is the half the rows above leave untested: with the + // target gone the empty-token backup has to be what the reader returns, not merely a name the + // scanner is willing to unlink. + it("restores a dot-anchored backup whose random token came out empty", async () => { + const leftover = path.join(dir, ".a.json.bak_1730000000000_.tmp") + await fs.unlink(target) + await fs.writeFile(leftover, JSON.stringify({ committed: true })) + expect(JSON.parse(await readFileWithMissingRetry(target))).toEqual({ committed: true }) + await expect(fs.access(leftover)).rejects.toMatchObject({ code: "ENOENT" }) + }) + // And the name the writer produces out of that same answer: safeWriteJson guarantees a token, so the // staged name carries the empty token's stand-in, and the staged prefix - where the token is the only // thing that attributes the name - still recognises it. diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 04823340dc..24a69615e7 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -968,20 +968,26 @@ describe("safeWriteJson", () => { // cannot collect on the underscore-anchored prefix, where the token is the only thing that // attributes the name to this target. The other half of the guarantee: a non-zero random still has // to produce its own token rather than a constant. - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-json-token-")) + // The suite's own directory rather than a second mkdtemp: this row writes two files and a rejection + // anywhere inside it would leave a per-test directory behind, whereas afterEach removes tempDir + // whatever happens. const stagedName = async (name: string): Promise => { - const file = path.join(dir, name) + const file = path.join(tempDir, name) await safeWriteJson(file, { committed: true }) const calls = vi.mocked(fs.rename).mock.calls // The commit rename is the last rename of the write: its source is the staging file. return path.basename(String(calls[calls.length - 1][0])) } const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0) - const zeroName = await stagedName("zero.json") - randomSpy.mockReturnValue(0.5) - const halfName = await stagedName("half.json") - randomSpy.mockRestore() - await fs.rm(dir, { recursive: true, force: true }) + let zeroName: string + let halfName: string + try { + zeroName = await stagedName("zero.json") + randomSpy.mockReturnValue(0.5) + halfName = await stagedName("half.json") + } finally { + randomSpy.mockRestore() + } expect(zeroName).toMatch(/^\.new_zero\.json_\d+_[^_]+\.tmp$/) expect(halfName).toMatch(/^\.new_half\.json_\d+_[0-9a-z]+\.tmp$/) @@ -990,6 +996,28 @@ describe("safeWriteJson", () => { expect(halfName).toContain("_i.tmp") }) + // The other builder that reads the same expression: the EPERM fallback moves the target to a backup + // name, and jsonFileRecovery only collects that name when the token part is non-empty. Forcing the + // Windows-style commit failure while Math.random() answers 0 reads the backup destination out of the + // rename that actually ran, so removing the fallback in createBackupFilePath cannot pass unnoticed. + it("backs up under a token that a zero random still fills when the commit rename hits EPERM", async () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0) + try { + // Cleared first: the mock's call history survives between tests in this file, and without this the + // assertion below could be satisfied by another test's backup rename. + vi.mocked(fs.rename).mockClear() + vi.mocked(fs.rename).mockImplementationOnce(async () => { + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + }) + await safeWriteJson(currentTestFilePath, { committed: true }) + const backupRename = vi.mocked(fs.rename).mock.calls.find((call) => String(call[1]).includes(".bak_")) + expect(backupRename).toBeDefined() + expect(path.basename(String(backupRename![1]))).toMatch(/^\.test-file\.json\.bak_\d+_[0-9a-z]+\.tmp$/) + } finally { + randomSpy.mockRestore() + } + }) + it("keeps the published target with its new content when the post-commit directory fsync fails", async () => { const fsSyncMod = await import("fs") const realFsync = fsSyncMod.fsyncSync From 780966d8ae00df661746b01c30fc61e13cbe5f05 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:26:59 +0800 Subject: [PATCH 35/38] file-safety: bind the commit rename to the identity the caller authorized rename has no conditional form, so the pathname re-check above it cannot bind an authorization made outside this module: the path can become a link, or a different file, between the re-check and the commit. When SafeWriteTextOptions.authorizedTarget carries the dev/ino the caller authorized, the commit step now refuses unless the target still has that identity, which narrows the window to the stat-plus-rename pair - it does not close it, because no OS primitive renames only if the identity is unchanged. A failure to read the identity counts as "not that file", so the guard fails closed. Answers PRRT_kwDOSK-bwM609gmQ (CodeRabbit row on #1917 at guardedWrite.ts:230), landing in #1911 as ruled. Pinned interpreter v22.23.4: tsc --noEmit -p tsconfig.json exit 0 with 0 error lines; eslint --prune-suppressions --max-warnings=0 exit 0 with src/eslint-suppressions.json deep-unchanged (265 entries, 0 differing); prettier 3.9.10 --check exit 0; the six suites that cover this file pass. Gate at this head against merge-base 73931fe8be25 with --unified=0: 496 of 500 counted executable lines (411 to 416 in this file), i.e. +5 as an increment over 1910's head after a rebase. --- .../__tests__/safeWriteText.identity.spec.ts | 57 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 19 +++++++ 2 files changed, 76 insertions(+) create mode 100644 src/services/file-safety/__tests__/safeWriteText.identity.spec.ts diff --git a/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts b/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts new file mode 100644 index 0000000000..4475875a8a --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts @@ -0,0 +1,57 @@ +import * as fs from "fs" +import * as os from "os" +import * as path from "path" + +import { safeWriteText } from "../safeWriteText" + +// `rename` has no conditional form: there is no way to ask it to fire only if the file under the +// destination pathname is still the file an authorization was made about. The guard below is +// therefore a comparison, made as late as it can be made, between the identity the caller handed +// in and the identity of the path about to be written - and it refuses rather than publishing +// when they disagree or when the identity cannot be read at all. These rows run against the real +// filesystem, with no stubbing of fs.stat, because the assertion that matters is about what the +// path actually is at the moment of the commit. +describe("safeWriteText authorized identity", () => { + let dir: string + beforeEach(async () => { + dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "safe-write-identity-")) + }) + afterEach(async () => { + await fs.promises.rm(dir, { recursive: true, force: true }) + }) + + it("publishes when the target is still the file the caller authorized", async () => { + const file = path.join(dir, "target.txt") + await fs.promises.writeFile(file, "before") + const authorized = await fs.promises.stat(file) + await safeWriteText(file, "after", { authorizedTarget: { dev: authorized.dev, ino: authorized.ino } }) + expect(await fs.promises.readFile(file, "utf8")).toBe("after") + }) + + // The pathname is the same and the file is not: that is exactly the case a textual re-check + // cannot see, because both spellings of the swap land on the same path. + it("refuses to publish over a different file that took the authorized path", async () => { + const file = path.join(dir, "target.txt") + await fs.promises.writeFile(file, "before") + const authorized = await fs.promises.stat(file) + await fs.promises.unlink(file) + await fs.promises.writeFile(file, "someone else's") + await expect( + safeWriteText(file, "after", { authorizedTarget: { dev: authorized.dev, ino: authorized.ino } }), + ).rejects.toThrow(/changed identity after it was authorized/) + expect(await fs.promises.readFile(file, "utf8")).toBe("someone else's") + }) + + // Failing closed: an unreadable identity is not the authorized file either, so the write refuses + // instead of letting a raw stat failure escape as something else. + it("refuses rather than reporting a raw stat failure when the authorized file is gone", async () => { + const file = path.join(dir, "target.txt") + await fs.promises.writeFile(file, "before") + const authorized = await fs.promises.stat(file) + await fs.promises.unlink(file) + await expect( + safeWriteText(file, "after", { authorizedTarget: { dev: authorized.dev, ino: authorized.ino } }), + ).rejects.toThrow(/changed identity after it was authorized/) + await expect(fs.promises.access(file)).rejects.toMatchObject({ code: "ENOENT" }) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 8e185b28a9..64e3a2479d 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -14,6 +14,14 @@ export interface SafeWriteTextOptions { */ backup?: boolean + /** + * The filesystem identity the caller authorized for this target. When present, the commit + * rename below refuses unless the target is still that same file, so an authorization made + * outside this module cannot be spent on a link or a different file swapped in for the pathname + * afterwards. A failure to read the identity counts as "not that file": the guard fails closed. + */ + authorizedTarget?: { dev: number; ino: number } + /** * Platform override for testing. When omitted the real process.platform * value is used. Set to "win32" or "linux" / "darwin" from tests so that @@ -735,6 +743,17 @@ export async function safeWriteText( ) } + // A pathname re-check cannot bind an authorization: the path can become a link, or a + // different file, after the re-check and before this rename, and a rename has no conditional + // form. Comparing identities conditions the commit on the file that was authorized, which + // narrows the window to this stat-plus-rename pair - the most a rename-based writer can do. + const authorizedTarget = options?.authorizedTarget + if (authorizedTarget) { + const identity = await fs.stat(targetPath).catch(() => undefined) + if (!identity || identity.dev !== authorizedTarget.dev || identity.ino !== authorizedTarget.ino) { + throw new Error(`safeWriteText: ${absoluteFilePath} changed identity after it was authorized`) + } + } await fs.rename(tempPath, targetPath) committed = true From 5559f1f67097a1caec4c95c2f14e02908005cbbc Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:41:21 +0800 Subject: [PATCH 36/38] file-safety: ask the identity guard about a different live file, not about a recycled inode The ubuntu platform-unit-test failed on the row this commit replaces: unlinking the target and writing a different file to the same pathname passed on Windows and failed on the runner, because the inode the filesystem had just released came back, so the swapped-in file answered with the identity the write had been authorized and the guard accepted it. Whether an inode number is reused is a property of the filesystem, not of this guard, so the row now compares against the identity of a second live file in the same directory - two files that are not hard links of each other cannot share an inode - and asserts that neither file was published over. Test-only: safeWriteText.ts is untouched, so the mutation gate's counted-line total stays 496 of 500 at base 73931fe8be25 with --unified=0, which is what CI prints. Verified with the pinned interpreter v22.23.4: the identity suite passes standalone (3 rows), the alias suite passes standalone (4 rows), eslint --prune-suppressions --max-warnings=0 exits 0 and prettier --check exits 0. --- .../__tests__/safeWriteText.identity.spec.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts b/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts index 4475875a8a..3439724c18 100644 --- a/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts @@ -28,18 +28,24 @@ describe("safeWriteText authorized identity", () => { expect(await fs.promises.readFile(file, "utf8")).toBe("after") }) - // The pathname is the same and the file is not: that is exactly the case a textual re-check - // cannot see, because both spellings of the swap land on the same path. - it("refuses to publish over a different file that took the authorized path", async () => { + // Two live files on one filesystem have different inodes unless they are hard links of each + // other, so the guard is asked about an identity that provably is not the target's. The first + // draft of this row unlinked the target and wrote a different file to the same pathname, which + // passed on Windows and failed on the ubuntu runner: the freshly freed inode came back, so the + // swapped-in file answered with the authorized identity and the guard correctly accepted it. + // A test that asserts what a filesystem does with an inode it just released is not a test of + // this guard. + it("refuses to publish when the target is not the file whose identity was authorized", async () => { const file = path.join(dir, "target.txt") + const other = path.join(dir, "other.txt") await fs.promises.writeFile(file, "before") - const authorized = await fs.promises.stat(file) - await fs.promises.unlink(file) - await fs.promises.writeFile(file, "someone else's") + await fs.promises.writeFile(other, "someone else's") + const otherIdentity = await fs.promises.stat(other) await expect( - safeWriteText(file, "after", { authorizedTarget: { dev: authorized.dev, ino: authorized.ino } }), + safeWriteText(file, "after", { authorizedTarget: { dev: otherIdentity.dev, ino: otherIdentity.ino } }), ).rejects.toThrow(/changed identity after it was authorized/) - expect(await fs.promises.readFile(file, "utf8")).toBe("someone else's") + expect(await fs.promises.readFile(file, "utf8")).toBe("before") + expect(await fs.promises.readFile(other, "utf8")).toBe("someone else's") }) // Failing closed: an unreadable identity is not the authorized file either, so the write refuses From 2e79e37ac0e73cee032c5c298834dd0d3b32e321 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:21:35 +0800 Subject: [PATCH 37/38] file-safety: authorize by bigint file identity, not by a rounded number The commit-time guard compared dev and ino as numbers. Both sides came from a plain stat, and a plain stat hands back a 64-bit NTFS file id rounded to a double: above 2**53 two different files can round to the same number, and the guard would then accept one for the other, which is exactly the swap it exists to catch. The lost bits cannot be recovered from the rounded value, so the fix is the capture spelling, not a smarter comparison: the option is now { dev: bigint; ino: bigint } and the guard reads the target with fs.stat(targetPath, { bigint: true }). The option has no production caller in this tree - the only seven mentions of authorizedTarget at 5559f1f67097 are the option declaration, the three guard lines, and the three rows in safeWriteText.identity.spec.ts - so this is the moment the spelling is free to change, and it is the last moment it will be free once sibling units start handing identities in. Measured on this box's NTFS volume, three identities came back as 281474990289684, 4503599627490809 and 5629499534337367, all below 2**53: this volume cannot exhibit the collision, so no row here asserts the rounding, and the rows are updated to read bigint identities rather than being asked to prove something the filesystem will not do for them. Verification, pinned node v22.23.4 with stdio on file descriptors: services/file-safety with utils/__tests__/safeWriteJson.test.ts is 133 passed and 1 skipped (the mode row skipped on win32, as before) in 4 files; tsc --noEmit -p tsconfig.json in src reports 0 errors; eslint --prune-suppressions --max-warnings=0 exits 0 on both touched files and its suppressions rewrite was reverted; prettier --check is clean on both, and formatting was done before measuring. Gate: 496 of 500 counted executable lines at base 73931fe8be253b93ec11103a0672c615e849f895 with base...head and --unified=0, against 496 at 5559f1f67097 - a 0-line move, because the type line and the comments are not executable - measured with v22.23.4. --- .../__tests__/safeWriteText.identity.spec.ts | 12 ++++++++---- src/services/file-safety/safeWriteText.ts | 13 +++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts b/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts index 3439724c18..dde9c5123e 100644 --- a/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts @@ -10,7 +10,11 @@ import { safeWriteText } from "../safeWriteText" // in and the identity of the path about to be written - and it refuses rather than publishing // when they disagree or when the identity cannot be read at all. These rows run against the real // filesystem, with no stubbing of fs.stat, because the assertion that matters is about what the -// path actually is at the moment of the commit. +// path actually is at the moment of the commit. The identities below are read with +// { bigint: true } because the guard compares bigints: a plain stat hands back a 64-bit NTFS file +// id rounded to a double, so the guard would be comparing rounded numbers, and above 2**53 two +// different files can round to the same one. This volume's measured ids are all below 2**53 (see +// the option's comment), so these rows exercise the comparison and not the rounding. describe("safeWriteText authorized identity", () => { let dir: string beforeEach(async () => { @@ -23,7 +27,7 @@ describe("safeWriteText authorized identity", () => { it("publishes when the target is still the file the caller authorized", async () => { const file = path.join(dir, "target.txt") await fs.promises.writeFile(file, "before") - const authorized = await fs.promises.stat(file) + const authorized = await fs.promises.stat(file, { bigint: true }) await safeWriteText(file, "after", { authorizedTarget: { dev: authorized.dev, ino: authorized.ino } }) expect(await fs.promises.readFile(file, "utf8")).toBe("after") }) @@ -40,7 +44,7 @@ describe("safeWriteText authorized identity", () => { const other = path.join(dir, "other.txt") await fs.promises.writeFile(file, "before") await fs.promises.writeFile(other, "someone else's") - const otherIdentity = await fs.promises.stat(other) + const otherIdentity = await fs.promises.stat(other, { bigint: true }) await expect( safeWriteText(file, "after", { authorizedTarget: { dev: otherIdentity.dev, ino: otherIdentity.ino } }), ).rejects.toThrow(/changed identity after it was authorized/) @@ -53,7 +57,7 @@ describe("safeWriteText authorized identity", () => { it("refuses rather than reporting a raw stat failure when the authorized file is gone", async () => { const file = path.join(dir, "target.txt") await fs.promises.writeFile(file, "before") - const authorized = await fs.promises.stat(file) + const authorized = await fs.promises.stat(file, { bigint: true }) await fs.promises.unlink(file) await expect( safeWriteText(file, "after", { authorizedTarget: { dev: authorized.dev, ino: authorized.ino } }), diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 64e3a2479d..e4fda9f0c7 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -19,8 +19,17 @@ export interface SafeWriteTextOptions { * rename below refuses unless the target is still that same file, so an authorization made * outside this module cannot be spent on a link or a different file swapped in for the pathname * afterwards. A failure to read the identity counts as "not that file": the guard fails closed. + * + * Both halves are bigint because they are read from `stat(..., { bigint: true })`, on both sides + * of the comparison. A plain stat returns a 64-bit NTFS file id rounded to a double, so above + * 2**53 two different files can be reported with the same number and the guard would accept one + * for the other; there is no way to recover the lost bits from the rounded value, so the capture + * spelling has to be right rather than the comparison. Measured on this box's NTFS volume, three + * identities came back as 281474990289684, 4503599627490809 and 5629499534337367 - all below + * 2**53, so this volume cannot exhibit the collision, which is why the fix is the type and the + * capture and not a test here. */ - authorizedTarget?: { dev: number; ino: number } + authorizedTarget?: { dev: bigint; ino: bigint } /** * Platform override for testing. When omitted the real process.platform @@ -749,7 +758,7 @@ export async function safeWriteText( // narrows the window to this stat-plus-rename pair - the most a rename-based writer can do. const authorizedTarget = options?.authorizedTarget if (authorizedTarget) { - const identity = await fs.stat(targetPath).catch(() => undefined) + const identity = await fs.stat(targetPath, { bigint: true }).catch(() => undefined) if (!identity || identity.dev !== authorizedTarget.dev || identity.ino !== authorizedTarget.ino) { throw new Error(`safeWriteText: ${absoluteFilePath} changed identity after it was authorized`) } From ad18ac405023222380961db57d3208cd505e3e26 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:06:24 +0800 Subject: [PATCH 38/38] file-safety: give the identity guard a row where the target changes mid-write The review on safeWriteText.identity.spec.ts asked for a row where the target is replaced between staging and the commit rename rather than before the call. The seams it needs are already in the writer: options.platform picks which step runs, and options.execFileRunner is the icacls call that step makes. platform: "win32" plus an injected runner now swaps the target for a second live file when icacls is asked to dump its DACL, which is after the temp is staged and before the commit-time identity check, so the guard fires from a change inside the call and the stranger keeps the pathname it was swapped in for. The swap renames the target aside instead of unlinking it so the authorized inode stays allocated - the unlink-and-rewrite shape is the one that agreed with itself on Windows and disagreed on the ubuntu runner, and a comment in this file records that trap. The second row reads the same gate from the other side: with the override saying linux the injected runner is never called. On a Windows host that path ends in PostCommitDurabilityError - fsync on a read-only handle is EPERM on win32, the same reason the backup fsync above opens its handle r+ - so the row asserts the seam and the committed content and admits no other error class. The third row asserts the clean publish and is skipped on win32 for that measured reason: the ubuntu CI arm is the instrument for it, and that arm's negative control is this same file with the override omitted, where process.platform is the platform under test rather than a value handed to the writer. Verification, pinned node v22.23.4 with stdio on file descriptors, run from wt-u2-1911/src through its src/node_modules (vitest 4.1.12, realpath Zoo-Code/node_modules/.pnpm/vitest@4.1.12_@opentelemetr_ 041b5b095ecf5a50bc5f59b473dbb794): services/file-safety identity, main and alias specs give 88 passed and 1 skipped in 3 files; tsc --noEmit -p tsconfig.json in src reports 0 errors; eslint --prune-suppressions --max-warnings=0 exits 0 on the touched file and its suppressions rewrite was reverted, so src/eslint-suppressions.json is byte-identical with HEAD; prettier --check is clean and formatting ran before measuring. Gate: 496 of 500 counted executable lines against 73931fe8be25 both before and after this commit - the delta is 103 lines in one spec file and specs charge nothing, and no production line changed. --- .../__tests__/safeWriteText.identity.spec.ts | 104 +++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts b/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts index dde9c5123e..8e0e56ae58 100644 --- a/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.identity.spec.ts @@ -2,7 +2,42 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" -import { safeWriteText } from "../safeWriteText" +import { PostCommitDurabilityError, safeWriteText } from "../safeWriteText" +import type { SafeWriteTextOptions } from "../safeWriteText" + +const IS_WINDOWS = process.platform === "win32" + +// Plays icacls for a test: records the flag it was asked for and, on its first call, moves *file* +// aside and leaves a different live file at that pathname. Renamed rather than unlinked, so the +// authorized inode stays allocated and cannot be handed back to the swap (the reuse that made the +// unlink-and-rewrite fixture above agree with itself on one runner and disagree on the other). The +// tail callback is read off the argument list because execFileRunner is typed as child_process +// .execFile, whose signatures take 2, 3 or 4 arguments. +// The return type needs an assertion, not a plain annotation: execFileRunner is typed as +// child_process.execFile, whose type carries a __promisify__ property that no plain function +// literal has, so the seam is built as a function and asserted into that shape. +function icaclsSeam(onDump?: [string, string], calls?: string[]): SafeWriteTextOptions["execFileRunner"] { + let callsMade = 0 + const seam = (...args: unknown[]): void => { + const tail = args[args.length - 1] + const finish = (error?: Error | null): void => { + if (typeof tail === "function") (tail as (e?: Error | null) => void)(error) + } + const argv = args[1] + calls?.push(Array.isArray(argv) ? String(argv[1] ?? "") : "") + if (onDump && callsMade++ === 0) { + const [swapFrom, swapAside] = onDump + void (async (): Promise => { + await fs.promises.rename(swapFrom, swapAside) + await fs.promises.writeFile(swapFrom, "someone else's") + finish() + })() + return + } + finish() + } + return seam as unknown as SafeWriteTextOptions["execFileRunner"] +} // `rename` has no conditional form: there is no way to ask it to fire only if the file under the // destination pathname is still the file an authorization was made about. The guard below is @@ -52,6 +87,73 @@ describe("safeWriteText authorized identity", () => { expect(await fs.promises.readFile(other, "utf8")).toBe("someone else's") }) + // The row the review asked for, in the shape its own suggestion named: the file under the pathname + // changes *during* the call rather than before it, through the seam the win32 step uses. platform + // is overridden to "win32" so the DACL step runs on either runner, and the runner swaps the target + // when icacls is asked to dump its DACL - after the temp is staged, before the commit-time identity + // check. A check made on entry had already chosen this pathname and would publish over the stranger. + it("refuses when a second live file takes the pathname while the write is in progress", async () => { + const file = path.join(dir, "target.txt") + const aside = path.join(dir, "authorized.txt") + const calls: string[] = [] + await fs.promises.writeFile(file, "before") + const authorized = await fs.promises.stat(file, { bigint: true }) + await expect( + safeWriteText(file, "after", { + authorizedTarget: { dev: authorized.dev, ino: authorized.ino }, + platform: "win32", + execFileRunner: icaclsSeam([file, aside], calls), + }), + ).rejects.toThrow(/changed identity after it was authorized/) + expect(calls).toEqual(["/save"]) // the win32 step ran, from the override alone + expect(await fs.promises.readFile(file, "utf8")).toBe("someone else's") + expect(await fs.promises.readFile(aside, "utf8")).toBe("before") + }) + + // The other side of the same gate: with the override reading linux, no runner reaches icacls. The + // commit rename is asserted through the durability error the linux arm can raise on a Windows host, + // because fsync on a read-only handle is EPERM on win32 - the same reason the backup fsync below + // opens its handle "r+" - so on win32 hosts this row sees PostCommitDurabilityError with the + // content already at the target, and on a POSIX host it sees no error at all, which the next row + // asserts where the runner can. + it(`leaves the icacls seam untouched when the platform override says linux`, async () => { + const file = path.join(dir, "target.txt") + const calls: string[] = [] + await fs.promises.writeFile(file, "before") + const authorized = await fs.promises.stat(file, { bigint: true }) + let failure: unknown = null + try { + await safeWriteText(file, "after", { + authorizedTarget: { dev: authorized.dev, ino: authorized.ino }, + platform: "linux", + execFileRunner: icaclsSeam(undefined, calls), + }) + } catch (error: unknown) { + failure = error + } + expect(calls).toEqual([]) + expect(await fs.promises.readFile(file, "utf8")).toBe("after") + if (failure !== null) expect(failure).toBeInstanceOf(PostCommitDurabilityError) + }) + + // The linux arm's own row, and the one this box cannot prove: a runner whose real platform is + // linux publishes through the same seam-free path with no error at all. It is skipped, not deleted, + // on Windows - the measured reason is the EPERM above - so the ubuntu CI arm is the instrument that + // proves the clean publish, and its negative control is this same file with the override omitted, + // where process.platform is the platform being tested rather than a value handed to the writer. + it.runIf(!IS_WINDOWS)(`publishes with no durability complaint when the platform override says linux`, async () => { + const file = path.join(dir, "target.txt") + const calls: string[] = [] + await fs.promises.writeFile(file, "before") + const authorized = await fs.promises.stat(file, { bigint: true }) + await safeWriteText(file, "after", { + authorizedTarget: { dev: authorized.dev, ino: authorized.ino }, + platform: "linux", + execFileRunner: icaclsSeam(undefined, calls), + }) + expect(calls).toEqual([]) + expect(await fs.promises.readFile(file, "utf8")).toBe("after") + }) // Failing closed: an unreadable identity is not the authorized file either, so the write refuses // instead of letting a raw stat failure escape as something else. it("refuses rather than reporting a raw stat failure when the authorized file is gone", async () => {