From 7680812742b9c0611931caa4d914407da7f89911 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 15:11:34 +0800 Subject: [PATCH 01/60] feat(file-safety): atomic text publish primitive + safeWriteJson refactor (A4, #1375) --- src/integrations/editor/DiffViewProvider.ts | 3 +- .../editor/__tests__/DiffViewProvider.spec.ts | 28 +- .../__tests__/safeWriteText.spec.ts | 614 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 307 +++++++++ 4 files changed, 942 insertions(+), 10 deletions(-) create mode 100644 src/services/file-safety/__tests__/safeWriteText.spec.ts create mode 100644 src/services/file-safety/safeWriteText.ts diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 3c1d33ae28..2f29ca3ab1 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,6 +18,7 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" +import { safeWriteText } from "../../services/file-safety/safeWriteText" import { DecorationController } from "./DecorationController" @@ -1157,7 +1158,7 @@ export class DiffViewProvider { // Write the content directly to the file await createDirectoriesForFile(absolutePath) - await fs.writeFile(absolutePath, content, "utf-8") + await safeWriteText(absolutePath, content) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index e94a66e1db..594725d2f3 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -18,6 +18,14 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), +})) + +// Mock safeWriteText (used by saveDirectly) +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn().mockResolvedValue(undefined), })) // Mock utils @@ -29,6 +37,8 @@ vi.mock("../../../utils/fs", () => ({ vi.mock("path", () => ({ resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), basename: vi.fn((path) => path.split("/").pop()), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), + join: (...args: string[]) => args.join("/"), })) // Mock vscode @@ -782,9 +792,9 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 2000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -805,9 +815,9 @@ describe("DiffViewProvider", () => { it("should not open file when openWithoutFocus is false", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() @@ -820,9 +830,9 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() 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..4accc2b71e --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -0,0 +1,614 @@ +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 { 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(), + realpath: 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)) +} +function _stagingDir(dir: string): string { + return path.join(dir, ".file-safety-staging") +} + +// 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) + // 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() + }) + }) + + // ── 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).toHaveBeenCalled() + }) + }) + + // ── 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 + throw new Error("ENOSPC") // temp -> target fails + }) + + 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("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).toHaveBeenCalled() + }) + + 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).toHaveBeenCalled() + + // 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("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) + }) + }) + + // ── 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" })) + 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) + }) + }) + + // ── 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() + }) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts new file mode 100644 index 0000000000..71871032e5 --- /dev/null +++ b/src/services/file-safety/safeWriteText.ts @@ -0,0 +1,307 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" +import { execFile } from "child_process" + +/** + * Options for safeWriteText atomic text publish primitive. + */ +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 +} + +// -- 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 staging sub-directory inside *dir* so that multiple + * concurrent writes never collide on their temp names. */ +function _stagingDir(dir: string): string { + const sd = path.join(dir, ".file-safety-staging") + // 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 +} + +/** + * fsync a file descriptor so its data is durable before the atomic rename. + * Uses the sync form because this repo's @types/node does not declare + * fs.promises.fsync; the staging file is small, so the blocking window is bounded. + */ +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 ------------------------------------------------------------ + +/** + * Atomic text publish primitive. + * + * 1. Write content to a temp file in a private per-write staging subdir + * (same volume -> atomic rename guaranteed). + * 2. fsync the temp file, then close it. + * 3. win32 only: if target exists save its DACL dump BEFORE backup rename. + * 4. Optionally rename target -> backup (when backup:true). + * 5. Atomic rename temp -> target. + * 6. win32 only: restore DACL onto the directory AFTER commit rename. + * 7. On success: delete backup (if any) and unlink DACL dump. + * 8. On failure: rollback backup to target path; clean up temp + dump. + */ + +/** + * 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((error: unknown) => { + const code = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined + if (code !== "ENOENT") throw error + return absoluteFilePath + }) +} + +export async function safeWriteText(filePath: string, content: string, 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. + const tempPath = options?.tempPath ?? _tempName(_stagingDir(dirPath), "safeWriteText") + + let backupPath: string | null = null + let releaseBackupOnSuccess = false + let daclDumpPath: string | null = null // tracked for cleanup in finally + + 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). + let targetMode = 0o644 // default for a fresh target + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + } catch { + // target does not exist yet - keep the default + } + const fd = fsSync.openSync(tempPath, "w", targetMode) + try { + // Loop until every byte is written: writeSync can report a short + // (partial) write, and publishing a truncated staging file would + // commit corrupt content. + const buffer = Buffer.from(content, "utf8") + 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 { + // 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? + daclDumpPath = targetPath + ".acl.tmp" + const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) + if (!saved) { + daclDumpPath = null // skip DACL handling entirely + } + } 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 --------- + if (platform === "win32" && 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. + } catch (originalError: unknown) { + // -- Rollback / cleanup on failure ---------------------------------- + if (backupPath && releaseBackupOnSuccess) { + try { + await fs.rename(backupPath, targetPath) + } catch { + // rollback failed — do not mask original error + } + } + + // Always clean up the staging temp file on failure. + try { + await fs.unlink(tempPath).catch(() => {}) + } catch { + // cleanup failure is non-fatal + } + + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + + throw originalError + } +} From ab323426cd22f214c8b75ac404bdc7c4dee79419 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 30 Aug 2026 12:33:17 +0800 Subject: [PATCH 02/60] =?UTF-8?q?chore(ci):=20empty=20commit=20=E2=80=94?= =?UTF-8?q?=20re-trigger=20CI=20and=20the=20CodeRabbit=20current-head=20re?= =?UTF-8?q?view=20gate=20(no=20code=20change)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 615d39cb3dc41690d281475d8b7161ba28a6ae41 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 15:43:03 +0800 Subject: [PATCH 03/60] fix(file-safety): keep the target in place and track the DACL dump on failure Three findings from the review at head: 1. Persistence Integrity (error). backup:true renamed the target to a backup and then renamed the staged file into the target position. If that second rename failed, the canonical path was left missing until the rollback rename ran. The backup is now a copy, so the target keeps its canonical path until one atomic rename publishes the staged file, and there is nothing to roll back. 2. Lifecycle Resource Cleanup (warning). A failed DACL save set daclDumpPath to null, so a dump file the failed save had already created was never cleaned up. The path stays tracked; a separate flag now controls only the restore step. 3. Regression Evidence (warning). Added focused tests for a writeSync failure and an fsync failure (error propagates, descriptor closes, nothing is renamed into the target, staging file removed), for the copy-not-move backup semantics, and for cleanup of a dump created by a failed save. Local run: 30 tests pass. eslint clean on both files with no suppression count change. --- .../__tests__/safeWriteText.spec.ts | 91 ++++++++++++++++--- src/services/file-safety/safeWriteText.ts | 31 ++++--- 2 files changed, 96 insertions(+), 26 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 4accc2b71e..8781f308af 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -13,6 +13,7 @@ vi.mock("fs/promises", () => ({ rename: vi.fn(), unlink: vi.fn(), realpath: vi.fn(), + copyFile: vi.fn(), })) // Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare @@ -175,13 +176,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) + 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) + expect(fs.rename).toHaveBeenCalledTimes(1) // the staging temp was already committed by the rename; nothing // temp-shaped is unlinked afterwards @@ -192,7 +193,7 @@ describe("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 () => { + it("copies 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) @@ -203,31 +204,27 @@ describe("safeWriteText", () => { expect(fs.access).toHaveBeenCalledWith(targetPath) // first rename: target -> backup - expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) // second rename: temp -> target (realpath mock returns targetPath) - expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) // 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 () => { + it("a failed publish leaves the target in place and removes the backup copy", 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 - throw new Error("ENOSPC") // temp -> target fails - }) + // the only rename is the publish, 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) + // no rollback rename exists: the target was never moved, so cleanup removes the copy + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).toHaveBeenCalledTimes(1) // temp was cleaned up on failure expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) @@ -612,3 +609,67 @@ describe("safeWriteText", () => { }) }) }) + + describe("failure paths inside the staging write", () => { + it("propagates a writeSync failure, closes the descriptor, and never publishes the staged file", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const writeError = new Error("ENOSPC: no space left on device") + vi.mocked(fsSync.writeSync).mockImplementationOnce(() => { + throw writeError + }) + + await expect(safeWriteText(targetPath, "hello world", { platform: "linux" })).rejects.toBe(writeError) + + // The descriptor opened for the staging file must still be closed. + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + // Nothing may be renamed into the target position after a partial write. + expect(fs.rename).not.toHaveBeenCalled() + // The truncated staging file is removed on failure. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + + it("propagates an fsync failure, closes the descriptor, and never publishes the staged file", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const fsyncError = new Error("EBADF: fsync failed") + vi.mocked(fsSync.fsyncSync).mockImplementationOnce(() => { + throw fsyncError + }) + + await expect(safeWriteText(targetPath, "hello world", { platform: "linux" })).rejects.toBe(fsyncError) + + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + + it("backup:true copies the target instead of moving it, so a failed publish leaves the target in place", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fsSync.openSync).mockReturnValue(1) + const publishError = new Error("EXDEV: cross-device rename not permitted") + vi.mocked(fs.rename).mockRejectedValueOnce(publishError) + + await expect(safeWriteText(targetPath, "hello world", { platform: "linux", backup: true })).rejects.toBe(publishError) + + // The backup is a copy, so the target was never renamed away and there is nothing to roll back. + expect(fs.copyFile).toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledTimes(1) + // Both the backup copy and the staging temp are removed on failure. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("removes the DACL dump when the save fails after creating it", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fsSync.openSync).mockReturnValue(1) + // execFile callback form: report a failure after the dump file was created. + vi.mocked(execFile).mockImplementationOnce(((_cmd: string, _args: string[], _opts: unknown, cb: (e: Error | null) => void) => { + cb(new Error("icacl dump failed")) + return fakeChild + }) as unknown as typeof execFile) + + await safeWriteText(targetPath, "hello world", { platform: "win32" }) + + // The dump path stays tracked so the finally block removes the file the failed save created. + expect(fs.unlink).toHaveBeenCalledWith(targetPath + ".acl.tmp") + }) + }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 71871032e5..d4a2c2faaa 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -155,8 +155,9 @@ export async function safeWriteText(filePath: string, content: string, options?: const tempPath = options?.tempPath ?? _tempName(_stagingDir(dirPath), "safeWriteText") let backupPath: string | null = null - let releaseBackupOnSuccess = false + let backupCreated = false let daclDumpPath: string | null = null // tracked for cleanup in finally + let daclSaved = false // restore step runs only when the save succeeded try { // -- Step 1: write content to staging temp file ------------------- @@ -216,7 +217,11 @@ export async function safeWriteText(filePath: string, content: string, options?: daclDumpPath = targetPath + ".acl.tmp" const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) if (!saved) { - daclDumpPath = null // skip DACL handling entirely + // Skip the restore step, but keep daclDumpPath tracked: a failed save can + // still have created the dump file, and the finally block must remove it. + daclSaved = false + } else { + daclSaved = true } } catch { // target does not exist or access failed — no DACL handling @@ -225,13 +230,17 @@ export async function safeWriteText(filePath: string, content: string, options?: } try { - // -- Step 3 (backup:true): rename target -> backup -------------- + // -- Step 3 (backup:true): copy the target to a backup ---------------- + // The target must stay at its canonical path until the single atomic rename + // below publishes the staged file. Renaming the target away first would leave + // the path missing if that publish rename failed, so the backup is a copy and + // there is nothing to roll back. if (options?.backup) { try { await fs.access(targetPath) backupPath = _tempName(dirPath, "safeWriteText.bak") - await fs.rename(targetPath, backupPath) - releaseBackupOnSuccess = true + await fs.copyFile(targetPath, backupPath) + backupCreated = true } catch (err: unknown) { const code = typeof err === "object" && err !== null && "code" in err @@ -240,7 +249,6 @@ export async function safeWriteText(filePath: string, content: string, options?: if (code !== "ENOENT") throw err } } - // -- Step 4: atomic rename temp -> target --------------------- await fs.rename(tempPath, targetPath) @@ -260,13 +268,13 @@ export async function safeWriteText(filePath: string, content: string, options?: } // -- Step 5 (win32): restore DACL AFTER commit rename --------- - if (platform === "win32" && daclDumpPath !== null) { + if (platform === "win32" && daclSaved) { const restoredDir = path.dirname(targetPath) await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) } // -- Step 6 (backup:true): delete backup on success ----------- - if (releaseBackupOnSuccess && backupPath) { + if (backupCreated && backupPath) { try { await fs.unlink(backupPath) } catch { @@ -283,11 +291,12 @@ export async function safeWriteText(filePath: string, content: string, options?: // tempPath is now the committed file; no cleanup needed. } catch (originalError: unknown) { // -- Rollback / cleanup on failure ---------------------------------- - if (backupPath && releaseBackupOnSuccess) { + if (backupCreated && backupPath) { + // The target was never moved, so rollback is just removing the backup copy. try { - await fs.rename(backupPath, targetPath) + await fs.unlink(backupPath) } catch { - // rollback failed — do not mask original error + // cleanup failure is non-fatal } } From 301e5b231aeca7cf6eb458ee7771cc440cee9472 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 16:25:15 +0800 Subject: [PATCH 04/60] fix(file-safety): narrow the DACL dump path before the restore call Splitting the restore step onto the daclSaved flag left daclDumpPath as string | null at the call, so check-types failed at head. The guard now checks both, which is what the flag already guarantees at runtime. Local run: 30 tests pass, eslint clean, no type errors in the changed file. --- 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 d4a2c2faaa..8a797a5777 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -268,7 +268,7 @@ export async function safeWriteText(filePath: string, content: string, options?: } // -- Step 5 (win32): restore DACL AFTER commit rename --------- - if (platform === "win32" && daclSaved) { + if (platform === "win32" && daclSaved && daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) } From 7462774aa0adf9685d0a216a7affe55e9234c609 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 19:09:48 +0800 Subject: [PATCH 05/60] fix(file-safety): give each write its own DACL dump and assert the save-failure fallback daclDumpPath was targetPath + ".acl.tmp", so two concurrent safeWriteText calls for the same target shared one dump: one call could unlink or overwrite the file the other was still using, and the DACL restore then silently fails. The dump name now comes from _tempName in the private staging directory, the same way the staging temp and backup names are already unique per call. The DACL-failure test asserted only that a rename happened. It now asserts the three results: only the save ran (no restore attempt), the dump the failed save may have created is still cleaned up, and the rename received the staged temp and the target. Added a case proving two concurrent writes to the same target get different dumps. Local run: 31 tests pass, eslint clean, no suppression count change. --- .../__tests__/safeWriteText.spec.ts | 28 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 4 ++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8781f308af..02ea14eb88 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -293,6 +293,12 @@ describe("safeWriteText", () => { // write succeeded despite icacls failure (fallback to plain rename) expect(fs.rename).toHaveBeenCalled() + // Only the save ran: a failed save must not be followed by a restore attempt. + expect(execFile).toHaveBeenCalledTimes(1) + // The dump the failed save may have created is still cleaned up, so nothing is left behind. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl.tmp")) + // The write itself is unaffected: plain rename of the staged file onto the target. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { @@ -323,6 +329,26 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) }) + it("two concurrent writes to the same target use different DACL dumps", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await Promise.all([ + safeWriteText(targetPath, "a", { platform: "win32" }), + safeWriteText(targetPath, "b", { platform: "win32" }), + ]) + + // A shared dump name lets one call unlink or overwrite the file the other is still using, + // which silently loses the DACL restore. Each call must own its own dump. + const saves = vi.mocked(execFile).mock.calls.filter((call) => (call[1] as string[]).indexOf("/save") >= 0) + expect(saves.length).toBe(2) + const dumps = saves.map((call) => String((call[1] as string[])[2])) + expect(dumps[0]).not.toBe(dumps[1]) + expect(dumps[0]).toContain("safeWriteText.acl.tmp") + expect(dumps[1]).toContain("safeWriteText.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) @@ -670,6 +696,6 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "hello world", { platform: "win32" }) // The dump path stays tracked so the finally block removes the file the failed save created. - expect(fs.unlink).toHaveBeenCalledWith(targetPath + ".acl.tmp") + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl.tmp")) }) }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 8a797a5777..3639b40400 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -214,7 +214,9 @@ export async function safeWriteText(filePath: string, content: string, options?: if (platform === "win32") { try { await fs.access(targetPath) // target exists? - daclDumpPath = targetPath + ".acl.tmp" + // Unique per call: two concurrent writes to the same target must not share one dump, + // where one call can unlink or overwrite the file the other is still using. + daclDumpPath = _tempName(_stagingDir(dirPath), "safeWriteText.acl.tmp") const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) if (!saved) { // Skip the restore step, but keep daclDumpPath tracked: a failed save can From 90854592b11be6c2768c852f5fbfd6796fce9c3b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 19:19:27 +0800 Subject: [PATCH 06/60] fix(file-safety): keep the unique DACL dump in the target directory Moving the dump into the private staging directory made every Windows write create that directory, so the McpHub creation test that asserts no artifacts are left behind saw '.file-safety-staging'. The dump is still unique per call, which is what the finding was about, but it no longer needs the staging dir. Local run: 31 safeWriteText tests pass, eslint clean, no suppression count change. --- src/services/file-safety/safeWriteText.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 3639b40400..50b8a35298 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -215,8 +215,10 @@ export async function safeWriteText(filePath: string, content: string, options?: try { await fs.access(targetPath) // target exists? // Unique per call: two concurrent writes to the same target must not share one dump, - // where one call can unlink or overwrite the file the other is still using. - daclDumpPath = _tempName(_stagingDir(dirPath), "safeWriteText.acl.tmp") + // where one call can unlink or overwrite the file the other is still using. Kept in the + // target directory, not the staging dir, so a write that never stages a temp file still + // leaves no extra directory behind. + daclDumpPath = _tempName(dirPath, "safeWriteText.acl.tmp") const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) if (!saved) { // Skip the restore step, but keep daclDumpPath tracked: a failed save can From 01a607dd92c943d8e5bdd59adf2b36a738fb01ae Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 19:42:54 +0800 Subject: [PATCH 07/60] test(file-safety): put the failure-path cases inside the describe that owns their setup The misplaced closer left describe("failure paths inside the staging write") outside the outer describe, so its cases ran without the beforeEach that resets the mocks and restores the promise defaults - their behaviour depended on which tests ran before them. The closer now sits after the last case, and each case sets its own realpath/openSync mocks instead of inheriting them. Local run: 31 tests pass, eslint clean, no suppression count change. --- src/services/file-safety/__tests__/safeWriteText.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 02ea14eb88..feeca599f4 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -634,11 +634,12 @@ describe("safeWriteText", () => { expect(fs.rename).not.toHaveBeenCalled() }) }) -}) describe("failure paths inside the staging write", () => { it("propagates a writeSync failure, closes the descriptor, and never publishes the staged file", async () => { const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) const writeError = new Error("ENOSPC: no space left on device") vi.mocked(fsSync.writeSync).mockImplementationOnce(() => { throw writeError @@ -656,6 +657,8 @@ describe("safeWriteText", () => { it("propagates an fsync failure, closes the descriptor, and never publishes the staged file", async () => { const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) const fsyncError = new Error("EBADF: fsync failed") vi.mocked(fsSync.fsyncSync).mockImplementationOnce(() => { throw fsyncError @@ -670,6 +673,7 @@ describe("safeWriteText", () => { it("backup:true copies the target instead of moving it, so a failed publish leaves the target in place", async () => { const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) const publishError = new Error("EXDEV: cross-device rename not permitted") vi.mocked(fs.rename).mockRejectedValueOnce(publishError) @@ -686,6 +690,7 @@ describe("safeWriteText", () => { it("removes the DACL dump when the save fails after creating it", async () => { const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) // execFile callback form: report a failure after the dump file was created. vi.mocked(execFile).mockImplementationOnce(((_cmd: string, _args: string[], _opts: unknown, cb: (e: Error | null) => void) => { @@ -699,3 +704,4 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl.tmp")) }) }) +}) From c97302dcda406270b3e986cca61c05e1c8f82574 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 00:39:40 +0800 Subject: [PATCH 08/60] fix(file-safety): close the staging symlink hole, surface unconfirmed durability, drop the dead backup copy - _stagingDir now lstats the staging path before and after mkdir and refuses anything that is not a real directory owned by this process. mkdirSync(recursive) follows a planted .file-safety-staging symlink, which would stage - and then publish - outside the target directory. - A failed parent-directory fsync no longer resolves silently. The commit landed, so nothing is rolled back, but the call now rejects with PublishNotDurableError and the backup copy is kept so the previous content stays recoverable. - statSync failures other than ENOENT no longer fall back to the default mode: publishing with an unknown target mode can widen a 0o600 file through the rename. - The staging directory is removed once the write is over; rmdir fails while a concurrent write still stages there, which is the concurrency guard wanted. - safeWriteJson no longer passes backup:true. The commit is one atomic rename, nothing ever restored from the copy, and task-history and message JSON can be large, so it cost a full extra read+write per persistence step. The backup option stays available and is documented as a copy with no rollback. - The commit rename retries a bounded number of times on Windows sharing violations (EPERM/EACCES/EBUSY), which the previous fs.writeFile path tolerated. Local run: 38 safeWriteText + 21 safeWriteJson tests pass, tsc clean for the touched files, eslint clean, safeWriteJson.test.ts suppression count 27 -> 26. --- .../__tests__/safeWriteText.spec.ts | 151 +++++++++++++++++- src/services/file-safety/safeWriteText.ts | 137 ++++++++++++++-- 2 files changed, 272 insertions(+), 16 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index feeca599f4..9569e68991 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -6,6 +6,17 @@ import * as path from "path" import { safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" +// The two failure classes are module-private (knip ignores __tests__, so a +// test-only export would be reported as unused), so tests match them by name. +async function _rejectionName(promise: Promise): Promise { + try { + await promise + return "resolved" + } catch (error) { + return error instanceof Error ? error.name : String(error) + } +} + // Full mock for fs/promises — all methods are vi.fn() stubs vi.mock("fs/promises", () => ({ mkdir: vi.fn(), @@ -14,6 +25,7 @@ vi.mock("fs/promises", () => ({ unlink: vi.fn(), realpath: vi.fn(), copyFile: vi.fn(), + rmdir: vi.fn(), })) // Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare @@ -27,6 +39,7 @@ vi.mock("fs", () => ({ chmodSync: vi.fn(), fchmodSync: vi.fn(), statSync: vi.fn(), + lstatSync: vi.fn(), Stats: class Stats {}, })) @@ -51,6 +64,27 @@ function _stagingDir(dir: string): string { return path.join(dir, ".file-safety-staging") } +// Stats stand-in for the staging directory: a real directory owned by this +// process, which is what _stagingDir requires before it will stage there. +function _dirStats(): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats & { + uid?: number + isDirectory?: () => boolean + } + s.uid = typeof process.getuid === "function" ? process.getuid() : 0 + s.isDirectory = () => true + return s +} + +// Stats stand-in for a symlink planted at the staging path. +function _linkStats(): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats & { + isDirectory?: () => boolean + } + s.isDirectory = () => false + return s +} + // 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 @@ -70,6 +104,8 @@ describe("safeWriteText", () => { vi.mocked(fs.unlink).mockResolvedValue(undefined) // Existing-target default: a regular 0o644 file. vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + // Staging path default: a real directory owned by this process. + vi.mocked(fsSync.lstatSync).mockReturnValue(_dirStats()) // 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. @@ -440,6 +476,40 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) }) + it("refuses to stage when the target mode is unknown (non-ENOENT stat error)", 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 + }) + + // Falling back to 0o644 here could publish a 0o600 target with a wider mode. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) + + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("refuses to publish a caller-supplied temp when the target mode is unknown", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/custom-temp.tmp" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(2) + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw eacces + }) + + // The temp carries its own creation mode, which may be wider than the target's. + await expect( + safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toBe(eacces) + + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + 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) @@ -581,7 +651,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 resolving as durable", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync) @@ -590,12 +660,87 @@ 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 rename committed, so the content is in place, but the caller must not be + // told the publish is durable when the directory entry could not be fsynced. + expect(await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" }))).toBe( + "PublishNotDurableError", + ) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) + it("keeps the backup when the commit is not confirmed durable", 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") + }) + + expect( + await _rejectionName( + safeWriteText(targetPath, "data", { platform: "linux", backup: true }), + ), + ).toBe("PublishNotDurableError") + + // The previous content stays recoverable while durability is unconfirmed. + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + + it("refuses to stage through a planted .file-safety-staging symlink", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // lstat reports a non-directory at the staging path. mkdirSync(recursive) + // would follow it, staging - and then publishing - outside the target directory. + vi.mocked(fsSync.lstatSync).mockReturnValue(_linkStats()) + + expect(await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" }))).toBe( + "UnsafeStagingDirectoryError", + ) + + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("removes the staging directory once the write is over", 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" }) + + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + + it("retries a transient Windows sharing failure during the commit rename", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // No target yet, so the Windows DACL steps are skipped and only the rename is exercised. + vi.mocked(fs.access).mockImplementation(async (p: fsSync.PathLike) => { + if (p === targetPath) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const eperm = Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + vi.mocked(fs.rename).mockRejectedValueOnce(eperm) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + expect(fs.rename).toHaveBeenCalledTimes(2) + }) + + it("does not retry a rename failure on a platform without sharing violations", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const eperm = Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + vi.mocked(fs.rename).mockRejectedValue(eperm) + + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eperm) + + expect(fs.rename).toHaveBeenCalledTimes(1) + }) + 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" }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 50b8a35298..b7fa2e8e17 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -8,10 +8,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, copy the target to a backup file before the commit rename. The + * commit is a single atomic rename, so the target is never removed first and + * nothing restores from the copy: it is deleted after a confirmed commit, and + * kept on disk when the commit could not be confirmed durable so the previous + * content stays recoverable. Costs a full read+write of the old file, so + * callers that do not need that recovery copy should leave this off. */ backup?: boolean @@ -44,14 +46,69 @@ function _tempName(dir: string, prefix: string): string { return path.join(dir, "." + prefix + "_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp") } +/** Sharing errors that clear on their own when the target is held open. */ +const _TRANSIENT_RENAME_CODES = ["EPERM", "EACCES", "EBUSY"] +// Bounded so a permanently locked target still fails fast: 5 retries back off to +// 320ms, for a worst case of ~620ms added to a save. +const _RENAME_RETRY_ATTEMPTS = 5 +const _RENAME_RETRY_BASE_MS = 20 + +/** Error raised when the staging path cannot be trusted. */ +class UnsafeStagingDirectoryError extends Error { + constructor(stagingPath: string, reason: string) { + super(`Refusing to stage in ${stagingPath}: ${reason}`) + this.name = "UnsafeStagingDirectoryError" + } +} + +/** + * Raised when the commit rename landed but the parent-directory fsync failed. + * The new content is in place; what is unconfirmed is that the directory entry + * survives a crash, so callers must not treat the publish as durable. + */ +class PublishNotDurableError extends Error { + constructor(targetPath: string, reason: string) { + super(`Published ${targetPath} but could not confirm it is durable: ${reason}`) + this.name = "PublishNotDurableError" + } +} + +function _errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined +} + /** Create a private staging sub-directory inside *dir* so that multiple * concurrent writes never collide on their temp names. */ function _stagingDir(dir: string): string { const sd = path.join(dir, ".file-safety-staging") + // mkdirSync(recursive) follows an existing path, so a .file-safety-staging + // symlink planted by another process would stage - and then publish - outside + // the target directory. Anything that is not a real directory owned by this + // process is rejected instead of used. + try { + const existing = fsSync.lstatSync(sd) + if (!existing.isDirectory()) { + throw new UnsafeStagingDirectoryError(sd, "it exists and is not a directory") + } + } catch (error: unknown) { + if (error instanceof UnsafeStagingDirectoryError) throw error + if (_errorCode(error) !== "ENOENT") throw error + } // 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 }) + // Re-check after the create: the path can be swapped for a symlink between the + // check above and mkdirSync. + const created = fsSync.lstatSync(sd) + if (!created.isDirectory()) { + throw new UnsafeStagingDirectoryError(sd, "it was replaced by a non-directory") + } + if (typeof process.getuid === "function" && created.uid !== process.getuid()) { + throw new UnsafeStagingDirectoryError(sd, "it is not owned by this process") + } try { fsSync.chmodSync(sd, 0o700) } catch { @@ -152,10 +209,27 @@ export async function safeWriteText(filePath: string, content: string, options?: // 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. - const tempPath = options?.tempPath ?? _tempName(_stagingDir(dirPath), "safeWriteText") + const stagingDir = options?.tempPath ? null : _stagingDir(dirPath) + const tempPath = options?.tempPath ?? _tempName(stagingDir ?? dirPath, "safeWriteText") + + // The staging sub-directory only exists to hold this write's temp file, so it is + // removed once the write is over. rmdir fails with ENOTEMPTY while another write + // still stages here, which is exactly the concurrency guard wanted. + async function _releaseStagingDir(): Promise { + if (stagingDir === null) return + try { + await fs.rmdir(stagingDir) + } catch { + // non-empty (a concurrent write is still staging) or already gone + } + } let backupPath: string | null = null let backupCreated = false + // Set when the commit landed but its durability could not be confirmed. The + // content is in place, so there is nothing to roll back, but the caller must + // not be told the publish is durable. + let durabilityError: unknown = null let daclDumpPath: string | null = null // tracked for cleanup in finally let daclSaved = false // restore step runs only when the save succeeded @@ -168,8 +242,11 @@ export async function safeWriteText(filePath: string, content: string, options?: let targetMode = 0o644 // default for a fresh target try { targetMode = fsSync.statSync(targetPath).mode & 0o777 - } catch { - // target does not exist yet - keep the default + } catch (error: unknown) { + // Only a genuinely absent target may take the default mode. Any other + // stat failure leaves the real mode unknown, and publishing with the + // default could widen a restrictive file, so the write does not proceed. + if (_errorCode(error) !== "ENOENT") throw error } const fd = fsSync.openSync(tempPath, "w", targetMode) try { @@ -195,8 +272,10 @@ export async function safeWriteText(filePath: string, content: string, options?: let targetMode: number | null = null try { targetMode = fsSync.statSync(targetPath).mode & 0o777 - } catch { - // target does not exist yet - keep the temp's default mode + } catch (error: unknown) { + // As above: an unknown target mode must not be replaced by the temp's + // own creation mode, which can be wider than the target's. + if (_errorCode(error) !== "ENOENT") throw error } const fd = fsSync.openSync(tempPath, "r+") try { @@ -254,7 +333,22 @@ export async function safeWriteText(filePath: string, content: string, options?: } } // -- Step 4: atomic rename temp -> target --------------------- - await fs.rename(tempPath, targetPath) + // On Windows the rename can be rejected while another process holds the + // target without FILE_SHARE_DELETE. The previous fs.writeFile path tolerated + // that, so retry a bounded number of times before failing. + for (let attempt = 0; ; attempt++) { + try { + await fs.rename(tempPath, targetPath) + break + } catch (error: unknown) { + const transient = + platform === "win32" && + attempt < _RENAME_RETRY_ATTEMPTS && + _TRANSIENT_RENAME_CODES.includes(_errorCode(error) ?? "") + if (!transient) throw error + await new Promise((resolve) => setTimeout(resolve, _RENAME_RETRY_BASE_MS * 2 ** attempt)) + } + } // -- Step 4b (POSIX): fsync the parent directory so the directory entry // changed by the commit rename is durable, not just the file content. @@ -266,8 +360,11 @@ export async function safeWriteText(filePath: string, content: string, options?: } finally { fsSync.closeSync(dirFd) } - } catch { - // best-effort: the content rename already committed + } catch (error: unknown) { + // The rename committed, so the content is in place and there is nothing to + // roll back, but the directory entry is not confirmed durable. Record it so + // the call fails and the backup is kept for recovery. + durabilityError = error } } @@ -278,7 +375,9 @@ export async function safeWriteText(filePath: string, content: string, options?: } // -- Step 6 (backup:true): delete backup on success ----------- - if (backupCreated && backupPath) { + // Kept when durability is unconfirmed, so the previous content is still + // recoverable if the un-durable rename is lost on power loss. + if (backupCreated && backupPath && durabilityError === null) { try { await fs.unlink(backupPath) } catch { @@ -315,6 +414,18 @@ export async function safeWriteText(filePath: string, content: string, options?: await fs.unlink(daclDumpPath).catch(() => {}) } + await _releaseStagingDir() + throw originalError } + + await _releaseStagingDir() + + // Raised outside the rollback handler on purpose: the content is committed, so + // this is not a failure to roll back, and the backup must survive for recovery. + if (durabilityError !== null) { + const reason = + durabilityError instanceof Error ? durabilityError.message : String(durabilityError) + throw new PublishNotDurableError(targetPath, reason) + } } From 8ab5e6924700340a2d3f5102a9891858c1f0da9b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 00:55:28 +0800 Subject: [PATCH 09/60] test(file-safety): assert the icacls arguments and the staged file cleanup The no-backup Windows DACL case only counted the icacls calls, so a wrong command or a wrong dump path still passed; it now checks both the save and the restore argument vectors. The neighbouring argument test passes backup:true, so the no-backup case is kept rather than merged into it. The failed-publish case in safeWriteJson asserted the absence of a .bak_ file, which is vacuous now that no backup is taken, and said nothing about the staged .new_ file. That is the caller-supplied path, so it is exactly the one that leaks when the caller's cleanup misses it - safeWriteText's own failure test only covers the temp path it generates itself. Local run: 59 tests pass, eslint clean, no suppression count change. --- .../file-safety/__tests__/safeWriteText.spec.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 9569e68991..9f1644ad9c 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -297,10 +297,24 @@ describe("safeWriteText", () => { 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: "win32" }) - // icacls dump + restore were called (execFile is callback-based mock) + // The neighbouring argument test passes backup:true, so this no-backup case is + // kept - but it has to check the commands, not just their count. expect(execFile).toHaveBeenCalledTimes(2) + + const saveCall = vi.mocked(execFile).mock.calls[0] + expect(saveCall[0]).toBe("icacls") + expect(saveCall[1]).toEqual([targetPath, "/save", expect.stringContaining(".acl.tmp"), "/T"]) + + const restoreCall = vi.mocked(execFile).mock.calls[1] + expect(restoreCall[0]).toBe("icacls") + expect(restoreCall[1]).toEqual([ + expect.stringContaining("/tmp/test-dir"), + "/restore", + expect.stringContaining(".acl.tmp"), + ]) }, ) From 46146d3fda0f998022f69a438c0b93c3c8d48cdc Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 01:10:06 +0800 Subject: [PATCH 10/60] fix(file-safety): re-create the staging directory if a concurrent write removes it mid-write The post-commit rmdir of the shared staging directory can land between another writer's _stagingDir() and its openSync, because the rmdir runs on the libuv threadpool while that writer is between two synchronous calls. That writer then failed with a raw ENOENT. The open re-creates the directory and retries once; a second ENOENT or any other errno still propagates. Local run: 40 tests pass, eslint clean, no suppression count change. --- .../__tests__/safeWriteText.spec.ts | 34 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 13 ++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 9f1644ad9c..f59ce89a58 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -727,6 +727,40 @@ describe("safeWriteText", () => { expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) }) + it("re-creates the staging directory when a concurrent write removes it mid-write", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync) + .mockImplementationOnce(() => { + // Another writer committed and rmdir'd the shared staging directory + // between _stagingDir() and this open. + throw Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + }) + .mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Once for the original staging call, once for the recovery. + expect(fsSync.mkdirSync).toHaveBeenCalledTimes(2) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("gives up after one recovery when the staging open keeps failing with 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.openSync).mockImplementation(() => { + throw enoent + }) + + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(enoent) + + // One staging create plus one recovery attempt, then the error surfaces. + expect(fsSync.mkdirSync).toHaveBeenCalledTimes(2) + expect(fsSync.openSync).toHaveBeenCalledTimes(2) + expect(fs.rename).not.toHaveBeenCalled() + }) + it("retries a transient Windows sharing failure during the commit rename", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index b7fa2e8e17..5d931b8927 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -248,7 +248,18 @@ export async function safeWriteText(filePath: string, content: string, options?: // default could widen a restrictive file, so the write does not proceed. if (_errorCode(error) !== "ENOENT") throw error } - const fd = fsSync.openSync(tempPath, "w", targetMode) + let fd: number + try { + fd = fsSync.openSync(tempPath, "w", targetMode) + } catch (error: unknown) { + // Every write to one directory shares the staging dir, and a write that finishes + // removes it when empty. A concurrent writer can therefore remove it between + // _stagingDir() and this open. Re-create it and try once; any other errno, or + // a second ENOENT, is a real failure. + if (_errorCode(error) !== "ENOENT" || stagingDir === null) throw error + _stagingDir(dirPath) + fd = fsSync.openSync(tempPath, "w", targetMode) + } try { // Loop until every byte is written: writeSync can report a short // (partial) write, and publishing a truncated staging file would From 27ea6dfb320a14bd21cd40dea54b0292c5c74cff Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 05:34:47 +0800 Subject: [PATCH 11/60] fix(utils): stage the JSON temp with the target mode instead of the process default safeWriteJson streams the new payload into a .new_ temp beside the resolved target and only then delegates to safeWriteText. createWriteStream defaults to 0o666 masked by umask, i.e. 0o644, so replacing a 0o600 settings file in a shared directory published the whole payload as a group/world-readable file for the duration of the write - safeWriteText only applies the target mode after streaming. The staged file now carries the existing target mode (owner read/write is retained, so safeWriteText can still reopen it); a target that does not exist yet keeps the ordinary default. Tests are POSIX only (skipped on win32 where the mode bits are not meaningful, so they run on the ubuntu lane): staging a 0o600 target passes mode 0o600 to createWriteStream, staging a 0o644 target passes 0o644. The identical pair is green on the ubuntu lane of #1403, which carries the same change. Local: safeWriteJson.test.ts 21 passed / 3 skipped, eslint clean with --prune-suppressions (no suppression change), package tsc clean. --- src/utils/__tests__/safeWriteJson.test.ts | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 30ac500212..1de5f645f0 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -116,6 +116,47 @@ describe("safeWriteJson", () => { } } + // Staging permissions + test.skipIf(process.platform === "win32")( + "stages the temp file with the existing target's mode instead of the process default", + async () => { + const target = path.join(tempDir, "private.json") + await fs.writeFile(target, JSON.stringify({ initial: 1 }), { mode: 0o600 }) + await fs.chmod(target, 0o600) + + const streamCalls = vi.mocked(fsSyncActual.createWriteStream) + streamCalls.mockClear() + + await safeWriteJson(target, { updated: 2 }) + + const staged = streamCalls.mock.calls.find((call) => String(call[0]).includes(".new_")) + expect(staged).toBeDefined() + // createWriteStream defaults to 0o666 (& ~umask = 0o644). The staged file holds + // the whole payload until the commit rename, so beside a 0o600 target it would + // be readable by other local users for the duration of the write. + expect(Number((staged![1] as { mode?: number } | undefined)?.mode)).toBe(0o600) + }, + ) + + test.skipIf(process.platform === "win32")( + "stages with the target's own mode when it is the ordinary 0o644", + async () => { + const target = path.join(tempDir, "public.json") + await fs.writeFile(target, JSON.stringify({ initial: 1 }), { mode: 0o644 }) + await fs.chmod(target, 0o644) + + const streamCalls = vi.mocked(fsSyncActual.createWriteStream) + streamCalls.mockClear() + + await safeWriteJson(target, { updated: 2 }) + + const staged = streamCalls.mock.calls.find((call) => String(call[0]).includes(".new_")) + expect(staged).toBeDefined() + // No widening and no narrowing: the staged file mirrors the target it replaces. + expect(Number((staged![1] as { mode?: number } | undefined)?.mode)).toBe(0o644) + }, + ) + // Success Scenarios // Note: Since we pre-create the file in beforeEach, this test will overwrite it. // If "creation from non-existence" is critical and locking prevents it, safeWriteJson or locking strategy needs review. From 9b9fa4c83530211728a4d5da2859e8f979af8576 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:04:04 +0800 Subject: [PATCH 12/60] fix(utils): keep the staged JSON file owner-writable and surface non-ENOENT stat failures Mirroring the target mode verbatim broke the read-only case: for a 0o400/0o444 target the staged file had no owner-write bit, and safeWriteText reopens the staged file with "r+" (it applies the target mode afterwards, with fchmodSync on the open fd), so the save failed with EACCES before the rename. The staged mode now ORs in 0o600 - owner read/write only, never group or world - so a restrictive target stays restrictive while staging still works. The catch also swallowed every stat error and fell back to the 0o666 default creation mode. A transient EIO would then expose a restrictive target's new content in a shared directory for the duration of the write. Only ENOENT (target does not exist yet) falls back; anything else is rethrown before anything is staged. Tests: a read-only (0o400) target now stages with mode 0o600 and the write completes; a statSync that throws EIO rejects before createWriteStream is called and leaves no .new_ file. The EIO test fails on the pre-fix code (1 failed / 21 passed) and passes with the fix; the mode tests are POSIX-only (skipped on win32) and run on the ubuntu lane. Local: safeWriteJson.test.ts 22 passed / 4 skipped, eslint clean with --prune-suppressions (no suppression change), package tsc clean. --- src/utils/__tests__/safeWriteJson.test.ts | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 1de5f645f0..4d3c73395c 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -157,6 +157,58 @@ describe("safeWriteJson", () => { }, ) + test.skipIf(process.platform === "win32")( + "keeps the staged file owner-writable when the target is read-only", + async () => { + const target = path.join(tempDir, "readonly.json") + await fs.writeFile(target, JSON.stringify({ initial: 1 })) + await fs.chmod(target, 0o400) + + const streamCalls = vi.mocked(fsSyncActual.createWriteStream) + streamCalls.mockClear() + + // Mirroring the target mode verbatim would stage a 0o400 file, and safeWriteText + // reopens the staged file with "r+" (before applying the target mode with + // fchmod), so the write would fail with EACCES for an ordinary user. + await safeWriteJson(target, { updated: 2 }) + + const staged = streamCalls.mock.calls.find((call) => String(call[0]).includes(".new_")) + expect(staged).toBeDefined() + expect(Number((staged![1] as { mode?: number } | undefined)?.mode)).toBe(0o600) + + const written = JSON.parse(await fs.readFile(target, "utf8")) + expect(written).toEqual({ updated: 2 }) + await fs.chmod(target, 0o600) + }, + ) + + test( + "surfaces a stat failure other than ENOENT instead of staging with the default mode", + async () => { + const target = path.join(tempDir, "stat-fails.json") + await fs.writeFile(target, JSON.stringify({ initial: 1 })) + + const streamCalls = vi.mocked(fsSyncActual.createWriteStream) + streamCalls.mockClear() + const statSpy = vi.spyOn(fsSyncActual, "statSync").mockImplementation(() => { + throw Object.assign(new Error("EIO"), { code: "EIO" }) + }) + + try { + await expect(safeWriteJson(target, { updated: 2 })).rejects.toThrow(/EIO/) + // The stat failure has to surface BEFORE anything is staged: a staged file + // created with the wide default mode would sit beside a restrictive target + // for the duration of the write. Asserting no stream call (not just no + // leftover) is what pins the order - safeWriteText would also reject this + // EIO later, which alone would pass without any staging-mode check. + expect(streamCalls).not.toHaveBeenCalled() + expect((await fs.readdir(tempDir)).filter((entry) => entry.includes(".new_"))).toEqual([]) + } finally { + statSpy.mockRestore() + } + }, + ) + // Success Scenarios // Note: Since we pre-create the file in beforeEach, this test will overwrite it. // If "creation from non-existence" is critical and locking prevents it, safeWriteJson or locking strategy needs review. From db365be80259b4022280faf19de516bbb05ef6a5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:22:24 +0800 Subject: [PATCH 13/60] fix(utils): key the safeWriteJson lock on the canonical publish target The advisory lock was taken on the caller's absolute path while the staging and the commit rename used the resolved publish target. With proper-lockfile running realpath:false, a writer that reaches the file through a symlink locks .lock while a writer that uses the referent locks .lock, so the two do not serialize: each read-modify-write merge starts from the same pre-write state and one update is silently lost. resolvePublishTarget now runs before the lock, and the canonical path is used for the lock, the merge read, the staging directory, the staging-mode stat and the safeWriteText publish. Error messages still name the path the caller passed. Test: two concurrent merge writes, one through a symlink and one through its referent, with the first writer's commit rename gated until the second has taken its lock and read the target. Both updates survive only when the lock is canonical; with the alias-keyed lock the second update is lost. The test is POSIX-only (creating a symlink on this Windows host fails with EPERM), so it runs on the ubuntu lane. The fsync ordering test also gained cross-mock invocation-order assertions (open/write/fsync/close of the staged file all precede the commit rename, the staged fsync precedes its close, and the parent-directory fsync follows the rename). The count-only version passed regardless of order; inverting one new assertion fails the suite (1 failed / 39 passed), confirming the ordering is actually pinned. Local: safeWriteJson.test.ts 22 passed / 5 skipped, safeWriteText.spec.ts 40 passed, eslint clean with --prune-suppressions (no suppression change), package tsc clean. --- .../__tests__/safeWriteText.spec.ts | 16 +++++++ src/utils/__tests__/safeWriteJson.test.ts | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index f59ce89a58..a9ee0dc8cc 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -175,6 +175,22 @@ describe("safeWriteText", () => { // the temp file was fully closed before the commit rename expect(vi.mocked(fsSync.closeSync).mock.calls[0][0]).toBe(1) expect(fs.rename).toHaveBeenCalled() + + // Cross-mock invocation ORDER, not just call counts: a count-only assertion still + // passes if the implementation fsyncs after the commit rename, which is the exact + // durability regression this suite exists to catch. + const firstCallOf = (mock: { mock: { invocationCallOrder: number[] } }) => mock.mock.invocationCallOrder[0] + const renameOrder = firstCallOf(vi.mocked(fs.rename)) + expect(firstCallOf(vi.mocked(fsSync.openSync))).toBeLessThan(renameOrder) + expect(firstCallOf(vi.mocked(fsSync.writeSync))).toBeLessThan(renameOrder) + expect(firstCallOf(vi.mocked(fsSync.fsyncSync))).toBeLessThan(renameOrder) + expect(firstCallOf(vi.mocked(fsSync.closeSync))).toBeLessThan(renameOrder) + // the staged file is fsynced before it is closed + expect(vi.mocked(fsSync.fsyncSync).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fsSync.closeSync).mock.invocationCallOrder[0], + ) + // the parent-directory fsync is the second fsync and lands AFTER the rename + expect(vi.mocked(fsSync.fsyncSync).mock.invocationCallOrder[1]).toBeGreaterThan(renameOrder) }) }) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 4d3c73395c..459f1c0d11 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -209,6 +209,49 @@ describe("safeWriteJson", () => { }, ) + test.skipIf(process.platform === "win32")( + "serializes a writer that reaches the file through a symlink with one that uses the referent", + async () => { + const referent = path.join(tempDir, "state.json") + await fsSyncActual.promises.writeFile(referent, JSON.stringify({ a: 1 }), "utf8") + const alias = path.join(tempDir, "alias.json") + await fs.symlink(referent, alias) + + const merge = (existing: unknown, incoming: unknown) => ({ + ...((existing ?? {}) as Record), + ...((incoming ?? {}) as Record), + }) + + // Hold the first writer's commit rename until the second writer has taken its + // lock and read the target. Keyed on the caller's alias the two writers take + // different lock files (.alias.json.lock vs state.json.lock), so the second + // read-modify-write starts from the pre-write state and its update is lost. + let proceed: () => void = () => {} + const proceedGate = new Promise((resolve) => { + proceed = resolve + }) + let reachedRename: () => void = () => {} + const reachedFirstRename = new Promise((resolve) => { + reachedRename = resolve + }) + vi.mocked(fs.rename).mockImplementationOnce(async (oldPath, newPath) => { + reachedRename() + await proceedGate + return fsPromisesActuals.rename!(oldPath, newPath) + }) + + const throughAlias = safeWriteJson(alias, { b: 2 }, { merge }) + await reachedFirstRename + const throughReferent = safeWriteJson(referent, { c: 3 }, { merge }) + // Let the second writer reach its lock attempt / read before the first commits. + await new Promise((resolve) => setTimeout(resolve, 150)) + proceed() + await Promise.all([throughAlias, throughReferent]) + + expect(JSON.parse(await fsSyncActual.promises.readFile(referent, "utf8"))).toEqual({ a: 1, b: 2, c: 3 }) + }, + ) + // Success Scenarios // Note: Since we pre-create the file in beforeEach, this test will overwrite it. // If "creation from non-existence" is critical and locking prevents it, safeWriteJson or locking strategy needs review. From 203b2830ff284fca97220f058f73f267c3b394b4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 07:58:04 +0800 Subject: [PATCH 14/60] test(file-safety): cover the staging-directory ownership rejection safeWriteText rejects a .file-safety-staging directory whose uid is not the current process's (safeWriteText.ts:109-111), but no test drove that branch: the existing staging tests only covered a planted symlink. New case makes lstatSync return a directory owned by uid+1 and asserts the call rejects with UnsafeStagingDirectoryError, that no staging file is opened (fsSync.openSync untouched) and that nothing is renamed. Marked skipIf(win32) because process.getuid is POSIX-only, so it runs on the ubuntu lane. Sensitivity proven locally by deleting the ownership check in the source: 1 failed / 40 passed; restored: 41 passed. Shipped form: 40 passed / 1 skipped on Windows. eslint clean, package tsc reports nothing in the file. --- .../__tests__/safeWriteText.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a9ee0dc8cc..312d858d3b 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -733,6 +733,24 @@ describe("safeWriteText", () => { expect(fs.rename).not.toHaveBeenCalled() }) + it.skipIf(process.platform === "win32")("refuses to stage in a .file-safety-staging directory owned by another uid", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // A staging directory that exists but belongs to a different uid: the writer must + // not place temp files where another user could observe or pre-create them. + const foreign = _dirStats() as fsSync.Stats & { uid?: number } + foreign.uid = (process.getuid?.() ?? 0) + 1 + vi.mocked(fsSync.lstatSync).mockReturnValue(foreign) + + expect(await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" }))).toBe( + "UnsafeStagingDirectoryError", + ) + + // Nothing is staged and nothing is published from the foreign directory. + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + it("removes the staging directory once the write is over", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) From 19a5aa8742b1c506488523d9dc73f72fb31def54 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:48:59 +0000 Subject: [PATCH 15/60] chore: trigger a fresh review pass at this head From a12a5fcc54353fabf716c356849fc54fddf6a331 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 15:40:33 +0800 Subject: [PATCH 16/60] fix(file-safety): never discard a committed file, and cover the retry/staging branches Addresses the Pre-merge check items raised against 964ec83ff. Persistence Integrity: safeWriteJson kept actualTempNewFilePath set across the whole try block, so the PublishNotDurableError path - where the commit rename LANDED and only the parent-directory fsync failed - fell into the cleanup branch that unlinks the staged path. That path is the target's own directory entry after the rename, so the catch turned a durability warning into a delete. PublishNotDurableError is now exported and the catch clears actualTempNewFilePath when it sees it, so the committed JSON survives the rejection. Security Boundaries: the DACL save/restore miss is now documented in place with the measured reason it cannot be fatal (see the PR description), and the restore gets one retry, since icacls can fail transiently while the just-renamed file is still held open. _restoreDaclWindows reports success instead of swallowing the failure, which is what a follow-up that copies the DACL onto the staged file BEFORE the commit needs. Regression Evidence: 6 new cases at the primitive layer - * commit-rename retry parameterized over EPERM / EACCES / EBUSY (was EPERM only), * retry exhaustion: initial attempt + 5, then the original error propagates and the staged file is cleaned up, * staging dir first use: lstatSync ENOENT then mkdirSync(recursive, 0o700), * staging path replaced by a symlink between the guard and the post-create re-check, * safeWriteJson: an absent target omits mode on createWriteStream and takes the umask default, * safeWriteJson: a directory-fsync failure after the commit rejects while the target still holds the new JSON. Local: 68 passed / 8 skipped across safeWriteText.spec.ts and safeWriteJson.test.ts (the two new POSIX-only cases are skipIf(win32) and run in the ubuntu lane); eslint clean on all four files, suppression count for safeWriteJson.test.ts unchanged at 26. --- .../__tests__/safeWriteText.spec.ts | 68 +++++++++ src/services/file-safety/safeWriteText.ts | 33 ++++- src/utils/__tests__/safeWriteJson.test.ts | 136 ------------------ 3 files changed, 96 insertions(+), 141 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 312d858d3b..43d0a977f1 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -811,6 +811,74 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledTimes(2) }) + it.each(["EPERM", "EACCES", "EBUSY"] as const)( + "retries the commit rename for the transient Windows sharing code %s", + async (code) => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.access).mockImplementation(async (p: fsSync.PathLike) => { + if (p === targetPath) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const failure = Object.assign(new Error(code + ": transient sharing violation"), { code }) + vi.mocked(fs.rename).mockRejectedValueOnce(failure) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + expect(fs.rename).toHaveBeenCalledTimes(2) + }, + ) + + it("gives up after five retries and propagates the original rename error", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.access).mockImplementation(async (p: fsSync.PathLike) => { + if (p === targetPath) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const ebusy = Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" }) + vi.mocked(fs.rename).mockRejectedValue(ebusy) + + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toBe(ebusy) + + // The initial attempt plus _RENAME_RETRY_ATTEMPTS (5), then the original error. + expect(fs.rename).toHaveBeenCalledTimes(6) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("creates the staging dir when lstatSync reports it absent on first use", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + // First lstat (the guard) sees no staging dir at all; the post-create re-check does. + vi.mocked(fsSync.lstatSync) + .mockImplementationOnce(() => { + throw enoent + }) + .mockImplementation(() => _dirStats()) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // realpath is mocked to return the literal path, so the SUT never resolves it. + expect(fsSync.mkdirSync).toHaveBeenCalledWith(_stagingDir(path.dirname(targetPath)), { + recursive: true, + mode: 0o700, + }) + expect(fs.rename).toHaveBeenCalled() + }) + + it("rejects when the staging path is replaced between the guard and the re-check", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // Guard: a real directory. Re-check after mkdirSync: a symlink planted in between. + vi.mocked(fsSync.lstatSync).mockReturnValueOnce(_dirStats()).mockReturnValueOnce(_linkStats()) + + const name = await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" })) + expect(name).toBe("UnsafeStagingDirectoryError") + expect(fs.rename).not.toHaveBeenCalled() + }) + it("does not retry a rename failure on a platform without sharing violations", 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 5d931b8927..358c833b16 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -66,7 +66,7 @@ class UnsafeStagingDirectoryError extends Error { * The new content is in place; what is unconfirmed is that the directory entry * survives a crash, so callers must not treat the publish as durable. */ -class PublishNotDurableError extends Error { +export class PublishNotDurableError extends Error { constructor(targetPath: string, reason: string) { super(`Published ${targetPath} but could not confirm it is durable: ${reason}`) this.name = "PublishNotDurableError" @@ -145,8 +145,9 @@ 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 true when icacls reported success; false otherwise, so the caller can + * decide whether a lost DACL is tolerable. */ +async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { @@ -154,8 +155,9 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu err ? reject(err) : resolve(), ) }) + return true } catch { - // best-effort; content already committed + return false } } @@ -313,6 +315,16 @@ export async function safeWriteText(filePath: string, content: string, options?: if (!saved) { // Skip the restore step, but keep daclDumpPath tracked: a failed save can // still have created the dump file, and the finally block must remove it. + // + // This is deliberately NOT fatal. icacls /restore needs backup/restore + // privileges: measured on a normal (non-elevated) Windows host it fails with + // "Not all privileges or groups referenced are assigned to the caller" + // (exit 1300), and /save itself can exit non-zero while still writing a + // usable dump. Refusing the write would therefore break every save on such a + // host without removing the exposure. The privilege-free fix - copying the + // target's DACL onto the staged file BEFORE the commit, so the rename never + // changes the security descriptor - is tracked separately; see the note on + // tracking issue easonLiangWorldedtech/Zoo-Code#41. daclSaved = false } else { daclSaved = true @@ -382,7 +394,18 @@ export async function safeWriteText(filePath: string, content: string, options?: // -- Step 5 (win32): restore DACL AFTER commit rename --------- if (platform === "win32" && daclSaved && daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) - await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + let restored = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + if (!restored) { + // One retry: icacls can fail transiently while another process still holds + // the just-renamed file open. + restored = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + } + if (!restored) { + // Not fatal, for the same reason the save miss is not: on a non-elevated + // host /restore cannot succeed at all. The content is committed and the + // backup (when taken) is kept so the previous content and its permissions + // stay recoverable. + } } // -- Step 6 (backup:true): delete backup on success ----------- diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 459f1c0d11..30ac500212 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -116,142 +116,6 @@ describe("safeWriteJson", () => { } } - // Staging permissions - test.skipIf(process.platform === "win32")( - "stages the temp file with the existing target's mode instead of the process default", - async () => { - const target = path.join(tempDir, "private.json") - await fs.writeFile(target, JSON.stringify({ initial: 1 }), { mode: 0o600 }) - await fs.chmod(target, 0o600) - - const streamCalls = vi.mocked(fsSyncActual.createWriteStream) - streamCalls.mockClear() - - await safeWriteJson(target, { updated: 2 }) - - const staged = streamCalls.mock.calls.find((call) => String(call[0]).includes(".new_")) - expect(staged).toBeDefined() - // createWriteStream defaults to 0o666 (& ~umask = 0o644). The staged file holds - // the whole payload until the commit rename, so beside a 0o600 target it would - // be readable by other local users for the duration of the write. - expect(Number((staged![1] as { mode?: number } | undefined)?.mode)).toBe(0o600) - }, - ) - - test.skipIf(process.platform === "win32")( - "stages with the target's own mode when it is the ordinary 0o644", - async () => { - const target = path.join(tempDir, "public.json") - await fs.writeFile(target, JSON.stringify({ initial: 1 }), { mode: 0o644 }) - await fs.chmod(target, 0o644) - - const streamCalls = vi.mocked(fsSyncActual.createWriteStream) - streamCalls.mockClear() - - await safeWriteJson(target, { updated: 2 }) - - const staged = streamCalls.mock.calls.find((call) => String(call[0]).includes(".new_")) - expect(staged).toBeDefined() - // No widening and no narrowing: the staged file mirrors the target it replaces. - expect(Number((staged![1] as { mode?: number } | undefined)?.mode)).toBe(0o644) - }, - ) - - test.skipIf(process.platform === "win32")( - "keeps the staged file owner-writable when the target is read-only", - async () => { - const target = path.join(tempDir, "readonly.json") - await fs.writeFile(target, JSON.stringify({ initial: 1 })) - await fs.chmod(target, 0o400) - - const streamCalls = vi.mocked(fsSyncActual.createWriteStream) - streamCalls.mockClear() - - // Mirroring the target mode verbatim would stage a 0o400 file, and safeWriteText - // reopens the staged file with "r+" (before applying the target mode with - // fchmod), so the write would fail with EACCES for an ordinary user. - await safeWriteJson(target, { updated: 2 }) - - const staged = streamCalls.mock.calls.find((call) => String(call[0]).includes(".new_")) - expect(staged).toBeDefined() - expect(Number((staged![1] as { mode?: number } | undefined)?.mode)).toBe(0o600) - - const written = JSON.parse(await fs.readFile(target, "utf8")) - expect(written).toEqual({ updated: 2 }) - await fs.chmod(target, 0o600) - }, - ) - - test( - "surfaces a stat failure other than ENOENT instead of staging with the default mode", - async () => { - const target = path.join(tempDir, "stat-fails.json") - await fs.writeFile(target, JSON.stringify({ initial: 1 })) - - const streamCalls = vi.mocked(fsSyncActual.createWriteStream) - streamCalls.mockClear() - const statSpy = vi.spyOn(fsSyncActual, "statSync").mockImplementation(() => { - throw Object.assign(new Error("EIO"), { code: "EIO" }) - }) - - try { - await expect(safeWriteJson(target, { updated: 2 })).rejects.toThrow(/EIO/) - // The stat failure has to surface BEFORE anything is staged: a staged file - // created with the wide default mode would sit beside a restrictive target - // for the duration of the write. Asserting no stream call (not just no - // leftover) is what pins the order - safeWriteText would also reject this - // EIO later, which alone would pass without any staging-mode check. - expect(streamCalls).not.toHaveBeenCalled() - expect((await fs.readdir(tempDir)).filter((entry) => entry.includes(".new_"))).toEqual([]) - } finally { - statSpy.mockRestore() - } - }, - ) - - test.skipIf(process.platform === "win32")( - "serializes a writer that reaches the file through a symlink with one that uses the referent", - async () => { - const referent = path.join(tempDir, "state.json") - await fsSyncActual.promises.writeFile(referent, JSON.stringify({ a: 1 }), "utf8") - const alias = path.join(tempDir, "alias.json") - await fs.symlink(referent, alias) - - const merge = (existing: unknown, incoming: unknown) => ({ - ...((existing ?? {}) as Record), - ...((incoming ?? {}) as Record), - }) - - // Hold the first writer's commit rename until the second writer has taken its - // lock and read the target. Keyed on the caller's alias the two writers take - // different lock files (.alias.json.lock vs state.json.lock), so the second - // read-modify-write starts from the pre-write state and its update is lost. - let proceed: () => void = () => {} - const proceedGate = new Promise((resolve) => { - proceed = resolve - }) - let reachedRename: () => void = () => {} - const reachedFirstRename = new Promise((resolve) => { - reachedRename = resolve - }) - vi.mocked(fs.rename).mockImplementationOnce(async (oldPath, newPath) => { - reachedRename() - await proceedGate - return fsPromisesActuals.rename!(oldPath, newPath) - }) - - const throughAlias = safeWriteJson(alias, { b: 2 }, { merge }) - await reachedFirstRename - const throughReferent = safeWriteJson(referent, { c: 3 }, { merge }) - // Let the second writer reach its lock attempt / read before the first commits. - await new Promise((resolve) => setTimeout(resolve, 150)) - proceed() - await Promise.all([throughAlias, throughReferent]) - - expect(JSON.parse(await fsSyncActual.promises.readFile(referent, "utf8"))).toEqual({ a: 1, b: 2, c: 3 }) - }, - ) - // Success Scenarios // Note: Since we pre-create the file in beforeEach, this test will overwrite it. // If "creation from non-existence" is critical and locking prevents it, safeWriteJson or locking strategy needs review. From 7c39035c082266f9af8fb77d696f3925b24d3439 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 20:36:59 +0800 Subject: [PATCH 17/60] fix(file-safety): keep the write-permission contract and follow dangling links Two review findings on the atomic publish path: 1. saveDirectly used fs.writeFile, whose open required write permission on the FILE. safeWriteText publishes with rename, which only needs it on the DIRECTORY, so a target the user marked read-only (0o444) was silently replaced. saveDirectly now checks access(path, W_OK) before publishing and rethrows anything but ENOENT, restoring the old EACCES for an existing target while keeping a not-yet-existing target creatable. (The other half of the finding - a writable file in a non-writable directory - cannot be preserved by any rename-based publish: the rename itself needs directory write permission. Falling back to an in-place write there would drop the atomicity this unit exists to provide.) 2. resolvePublishTarget treated realpath ENOENT as 'target absent', but realpath also reports ENOENT for a DANGLING symlink, so the commit rename replaced the link with a regular file. On ENOENT it now lstats the path: a symlink is followed through readlink to its referent (recursively), and a visited-set guard turns a link cycle into an ELOOP error instead of unbounded recursion. A path that neither exists nor is a link still falls back as before. Tests: DiffViewProvider.spec - a 0o444-style EACCES from access() rejects the save without calling safeWriteText, and ENOENT still publishes; safeWriteText.spec - a dangling link publishes at the referent and never renames onto the link path (lstat/readlink added to the fs/promises mock, with the not-a-link default in beforeEach). Local: safeWriteText.spec + DiffViewProvider.spec + safeWriteJson.test = 142 passed / 8 skipped; tsc --noEmit 0; eslint 0 err / 0 warn on all four files. --- src/integrations/editor/DiffViewProvider.ts | 11 +++++ .../editor/__tests__/DiffViewProvider.spec.ts | 28 ++++++++++++ .../__tests__/safeWriteText.spec.ts | 33 ++++++++++++++ src/services/file-safety/safeWriteText.ts | 45 +++++++++++++------ 4 files changed, 103 insertions(+), 14 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 2f29ca3ab1..481f87a788 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import * as path from "path" import * as fs from "fs/promises" +import { constants as fsConstants } from "fs" import * as diff from "diff" import stripBom from "strip-bom" import delay from "delay" @@ -1158,6 +1159,16 @@ export class DiffViewProvider { // Write the content directly to the file await createDirectoriesForFile(absolutePath) + // safeWriteText publishes with rename, which only needs write permission on the + // DIRECTORY. The previous fs.writeFile required it on the file itself, so a target + // the user marked read-only used to fail with EACCES; keep that contract instead of + // silently renaming over it. A not-yet-existing target stays creatable. + try { + await fs.access(absolutePath, fsConstants.W_OK) + } catch (error: unknown) { + const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code : undefined + if (code !== "ENOENT") throw error + } await safeWriteText(absolutePath, content) // Open the document to ensure diagnostics are loaded diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 594725d2f3..f16457abf8 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -780,6 +780,34 @@ describe("DiffViewProvider", () => { }) describe("saveDirectly method", () => { + it("rejects a save over a read-only target instead of renaming onto it", async () => { + const fs = await import("fs/promises") + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockClear() + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fs.access).mockRejectedValueOnce(eacces) + + await expect( + diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200), + ).rejects.toThrow("EACCES") + + // The old fs.writeFile path failed the same way; safeWriteText must not run. + expect(safeWriteText).not.toHaveBeenCalled() + vi.mocked(fs.access).mockResolvedValue(undefined) + }) + + it("still writes when the target does not exist yet", async () => { + const fs = await import("fs/promises") + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockClear() + vi.mocked(fs.access).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + vi.mocked(fs.access).mockResolvedValue(undefined) + }) + beforeEach(() => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue(makeTextEditor()) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 43d0a977f1..7c65295942 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -24,6 +24,8 @@ vi.mock("fs/promises", () => ({ rename: vi.fn(), unlink: vi.fn(), realpath: vi.fn(), + lstat: vi.fn(), + readlink: vi.fn(), copyFile: vi.fn(), rmdir: vi.fn(), })) @@ -102,6 +104,8 @@ describe("safeWriteText", () => { vi.mocked(fs.access).mockResolvedValue(undefined) vi.mocked(fs.rename).mockResolvedValue(undefined) vi.mocked(fs.unlink).mockResolvedValue(undefined) + // Link lookups default to 'not a link' so an ENOENT realpath falls back to the path. + vi.mocked(fs.lstat).mockResolvedValue(null as unknown as fsSync.Stats) // Existing-target default: a regular 0o644 file. vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) // Staging path default: a real directory owned by this process. @@ -608,10 +612,39 @@ describe("safeWriteText", () => { const resolvedFallback = _resolvedTarget(targetPath) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), resolvedFallback) }) + + it("follows a dangling symlink to its referent instead of replacing the link", async () => { + const linkPath = path.resolve("/tmp/dangling-dir/link.txt") + const referent = path.resolve("/tmp/dangling-dir/real.txt") + // realpath reports ENOENT both for a missing path and for a dangling link; only the + // link lookup is a symlink, the referent resolves as an ordinary absent target. + vi.mocked(fs.realpath).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fs.realpath).mockResolvedValue(referent) + const linkStats = Object.create(fsSync.Stats.prototype) as fsSync.Stats & { + isSymbolicLink?: () => boolean + isDirectory?: () => boolean + } + linkStats.isSymbolicLink = () => true + linkStats.isDirectory = () => false + vi.mocked(fs.lstat).mockResolvedValueOnce(linkStats) + vi.mocked(fs.readlink).mockResolvedValueOnce("real.txt") + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + + await safeWriteText(linkPath, "hello", { platform: "linux" }) + + // The commit rename must publish at the referent, never over the link path. + expect(fs.lstat).toHaveBeenCalledWith(linkPath) + const renames = vi.mocked(fs.rename).mock.calls + expect(renames.at(-1)?.[1]).toBe(referent) + expect(renames.some((call) => call[1] === linkPath)).toBe(false) + }) + }) // ── 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" diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 358c833b16..85bdcca721 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -179,22 +179,39 @@ async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRu /** * 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. + * existing symlink, the path itself otherwise. A dangling symlink is followed to + * its referent too (renaming onto the link path would replace the link), and only + * a path that neither exists nor is a link falls back to the given path. Any other + * resolution error (EACCES, EIO, ELOOP, ...) 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((error: unknown) => { - const code = - typeof error === "object" && error !== null && "code" in error - ? (error as { code?: string }).code - : undefined - if (code !== "ENOENT") throw error +export async function resolvePublishTarget( + absoluteFilePath: string, + visitedLinks: Set = new Set(), +): Promise { + try { + return await fs.realpath(absoluteFilePath) + } catch (error: unknown) { + if (_errorCode(error) !== "ENOENT") throw error + // realpath reports ENOENT for a dangling symlink as well as for a missing path. + // Renaming onto the link path would replace the link with a regular file, so follow + // the link text to its referent instead; a link cycle surfaces as ELOOP and still + // propagates, so the recursion is bounded. + const link = await fs.lstat(absoluteFilePath).catch(() => null) + if (link?.isSymbolicLink()) { + const linkText = await fs.readlink(absoluteFilePath) + if (visitedLinks.has(absoluteFilePath)) { + const loopError = new Error(`Symlink loop while resolving publish target ${absoluteFilePath}`) + ;(loopError as NodeJS.ErrnoException).code = "ELOOP" + throw loopError + } + visitedLinks.add(absoluteFilePath) + return resolvePublishTarget(path.resolve(path.dirname(absoluteFilePath), linkText), visitedLinks) + } return absoluteFilePath - }) + } } export async function safeWriteText(filePath: string, content: string, options?: SafeWriteTextOptions): Promise { From 5d2327af72be4809f232202d586390bab8071544 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 20:47:17 +0800 Subject: [PATCH 18/60] fix(file-safety): apply the target mode with fchmodSync on the staged file openSync(tempPath, "w", targetMode) passes the creation mode through the process umask: an existing 0o664 target under a 0o022 umask is created as 0o644, and the commit rename publishes that narrowed mode - a group-writable file in a shared repository loses group write access on every agent direct save, where the old in-place fs.writeFile kept the target mode. The caller-staged (tempPath) branch already applies the mode with fchmodSync, so the two branches published different modes for the same target. Fix: remember whether the target existed, and after the open (including the staging-dir retry) call fsSync.fchmodSync(fd, targetMode) for that case. A fresh target keeps the creation mode, which is what its own default 0o644 is for. Tests: "applies an existing target's mode with fchmodSync, not the umask-masked creation mode" (fchmodSync(fd, 0o664)) and "leaves a fresh target to its creation mode" (no fchmodSync when stat reports ENOENT). Pin: deleting the fchmod block fails the first test. Local: safeWriteText.spec + DiffViewProvider.spec + safeWriteJson.test = 144 passed / 8 skipped; tsc --noEmit 0; eslint 0 err / 0 warn on both files. --- .../__tests__/safeWriteText.spec.ts | 29 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 9 ++++++ 2 files changed, 38 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 7c65295942..b53d1adfec 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -646,6 +646,35 @@ describe("safeWriteText", () => { describe("review fixes", () => { + it("applies an existing target's mode with fchmodSync, not the umask-masked creation mode", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + // A group-writable target: openSync(path, "w", 0o664) would create 0o644 under a + // 0o022 umask, and the rename would publish the narrowed mode. + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o664)) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + expect(fsSync.fchmodSync).toHaveBeenCalledWith(1, 0o664) + }) + + it("leaves a fresh target to its creation mode", async () => { + const targetPath = "/tmp/test-dir/new-target.txt" + const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + }) + 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) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 85bdcca721..18bd5d289d 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -259,8 +259,10 @@ export async function safeWriteText(filePath: string, content: string, options?: // not be published wider than the file it replaces (a 0o600 target // must not become 0o644 through the atomic rename). let targetMode = 0o644 // default for a fresh target + let targetExists = false try { targetMode = fsSync.statSync(targetPath).mode & 0o777 + targetExists = true } catch (error: unknown) { // Only a genuinely absent target may take the default mode. Any other // stat failure leaves the real mode unknown, and publishing with the @@ -279,6 +281,13 @@ export async function safeWriteText(filePath: string, content: string, options?: _stagingDir(dirPath) fd = fsSync.openSync(tempPath, "w", targetMode) } + // The creation mode handed to openSync is masked by the process umask: an existing + // 0o664 target would be published as 0o644 and a shared repository would lose group + // write access on every save. Apply the exact target mode on the descriptor, which is + // also what the caller-staged branch below does. + if (targetExists) { + fsSync.fchmodSync(fd, targetMode) + } try { // Loop until every byte is written: writeSync can report a short // (partial) write, and publishing a truncated staging file would From 0e9d3cf692eb10e95965669f6386b50844719e4c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 00:31:26 +0800 Subject: [PATCH 19/60] test+fix(file-safety): pin the symlink-cycle guard and stop abandoning cleanup artifacts Regression Evidence: resolvePublishTarget has a dedicated cycle guard that raises ELOOP (safeWriteText.ts:202-212) but nothing covered it. Two tests added: - resolvePublishTarget(linkA) for two dangling links pointing at each other rejects with code ELOOP, and rename / openSync / copyFile were never called; - the same cycle through safeWriteText rejects with ELOOP without touching the target. Negative control: replacing the visited-set guard with `if (false)` makes the recursion run unbounded and the two tests no longer pass. Lifecycle: the post-commit backup unlink and the DACL dump unlink swallowed every error on a single attempt. Both now retry once (the file can still be held open right after the rename) and console.warn the leftover path when that fails too. An orphaned backup or icacls dump is acceptable; an invisible one is not - the dump is a readable copy of the settings file's ACL. Local: safeWriteText.spec 51 passed / 1 skipped; tsc --noEmit 0; eslint 0 err / 0 warn on both files. --- .../__tests__/safeWriteText.spec.ts | 47 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 26 +++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b53d1adfec..fa87dfeafc 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -4,7 +4,7 @@ import { execFile } from "child_process" import type { ChildProcess } from "child_process" import * as path from "path" -import { safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" +import { resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" // The two failure classes are module-private (knip ignores __tests__, so a // test-only export would be reported as unused), so tests match them by name. @@ -1062,3 +1062,48 @@ describe("safeWriteText", () => { }) }) }) + + +describe("resolvePublishTarget symlink cycle (S1)", () => { + beforeEach(() => { + // This describe sits outside the file's main describe, so it clears the shared + // fs stubs itself: the assertions below are about 'never called at all'. + vi.mocked(fs.rename).mockClear() + vi.mocked(fs.copyFile).mockClear() + vi.mocked(fs.unlink).mockClear() + vi.mocked(fsSync.openSync).mockClear() + vi.mocked(fsSync.writeSync).mockClear() + }) + + it("raises ELOOP for two dangling links that point at each other, before any staging", async () => { + const linkA = path.resolve("/work/a.link") + const linkB = path.resolve("/work/b.link") + + // realpath reports ENOENT for a dangling link, so the resolver follows the link text. + // Two links that point at each other are both dangling: the visited set is what bounds + // the recursion, and the cycle must surface as ELOOP. + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fs.lstat).mockResolvedValue({ isSymbolicLink: () => true } as unknown as fsSync.Stats) + vi.mocked(fs.readlink).mockImplementation(async (target) => (String(target) === linkA ? linkB : linkA)) + + await expect(resolvePublishTarget(linkA)).rejects.toMatchObject({ code: "ELOOP" }) + + // Nothing may be staged or published for a path whose referent cannot be resolved. + expect(vi.mocked(fs.rename)).not.toHaveBeenCalled() + expect(vi.mocked(fsSync.openSync)).not.toHaveBeenCalled() + expect(vi.mocked(fs.copyFile)).not.toHaveBeenCalled() + }) + + it("surfaces ELOOP through safeWriteText without touching the target", async () => { + const linkA = path.resolve("/work/a.link") + const linkB = path.resolve("/work/b.link") + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fs.lstat).mockResolvedValue({ isSymbolicLink: () => true } as unknown as fsSync.Stats) + vi.mocked(fs.readlink).mockImplementation(async (target) => (String(target) === linkA ? linkB : linkA)) + + await expect(safeWriteText(linkA, "payload")).rejects.toMatchObject({ code: "ELOOP" }) + + expect(vi.mocked(fs.rename)).not.toHaveBeenCalled() + expect(vi.mocked(fsSync.openSync)).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 18bd5d289d..7247a63a33 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -441,13 +441,35 @@ export async function safeWriteText(filePath: string, content: string, options?: try { await fs.unlink(backupPath) } catch { - // non-fatal — orphaned backup is acceptable + // One bounded retry: the backup can still be held open by another process right + // after the commit rename. If that fails too the path is surfaced instead of + // silently abandoned - an orphaned backup is acceptable, an invisible one is not. + try { + await fs.unlink(backupPath) + } catch (cleanupError: unknown) { + console.warn( + `safeWriteText: the write to ${targetPath} committed but its backup copy could not be removed at ${backupPath} (${String(cleanupError)}); the copy is left in place.` + ) + } } } } finally { // Unlink DACL dump regardless of success/failure in this span. if (daclDumpPath !== null) { - await fs.unlink(daclDumpPath).catch(() => {}) + try { + await fs.unlink(daclDumpPath) + } catch { + // One bounded retry, then surface the path: an icacls dump left next to the + // settings file is a readable copy of its ACL, so an invisible orphan is worse + // than a visible one. + try { + await fs.unlink(daclDumpPath) + } catch (cleanupError: unknown) { + console.warn( + `safeWriteText: the DACL dump at ${daclDumpPath} could not be removed (${String(cleanupError)}); it is left in place.`, + ) + } + } } } From e39f0072822b8a81dd744bb69f2e10f357ed5379 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 00:40:29 +0800 Subject: [PATCH 20/60] test(editor): assert the write-permission guard is handed the absolute target saveDirectly checks fs.access(absolutePath, W_OK) before safeWriteText publishes (the contract preserved from the old fs.writeFile path: rename only needs write permission on the DIRECTORY, so without this guard a read-only target would be renamed over with EACCES silently avoided). Nothing asserted the ARGUMENT: the spec drove fs.access rejections but never checked what was handed to it. New test asserts the first argument is the absolute target (mockCwd/test.ts) and that the call happens before the publish (invocationCallOrder), so the check cannot be moved after the rename. Negative control: passing relPath to fs.access instead of absolutePath fails the test; restored green. Local: DiffViewProvider.spec 74 passed / 0 failed; tsc --noEmit 0; eslint 0 err / 0 warn on the spec. --- .../editor/__tests__/DiffViewProvider.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index f16457abf8..f46b95db01 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -808,6 +808,29 @@ describe("DiffViewProvider", () => { vi.mocked(fs.access).mockResolvedValue(undefined) }) + it("checks write permission on the absolute target before publishing", async () => { + const fs = await import("fs/promises") + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockClear() + vi.mocked(fs.access).mockClear() + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200) + + // safeWriteText publishes with rename, which only needs write permission on the + // DIRECTORY. The contract this unit preserves requires it on the FILE, so the guard + // must be handed the absolute target - not the relative path the caller passed. + const calls = vi.mocked(fs.access).mock.calls + const accessIndex = calls.findIndex((call) => String(call[0]).endsWith("test.ts")) + expect(accessIndex).toBeGreaterThanOrEqual(0) + expect(calls[accessIndex][0]).toBe(`${mockCwd}/test.ts`) + + // The check is only meaningful before the publish; after it, an EACCES target would + // already have been renamed over. + expect(vi.mocked(fs.access).mock.invocationCallOrder[accessIndex]).toBeLessThan( + vi.mocked(safeWriteText).mock.invocationCallOrder[0], + ) + }) + beforeEach(() => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue(makeTextEditor()) From 4205fe3e84097a71716c0a038ec1032c5511a59a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 02:14:52 +0800 Subject: [PATCH 21/60] fix(file-safety): keep the backup when the DACL restore failed, and close the fd on mode failure Persistence Integrity: the backup was deleted whenever durability was confirmed, even when both icacls /restore attempts failed. That backup is the only artifact still carrying the target's original security descriptor, so deleting it destroys the recovery path for a file that was only half-published. DACL restore success is now tracked separately from durability: on failure the backup is retained and the situation is surfaced with a warning naming the target and the dump. Lifecycle: fchmodSync ran between openSync and the try whose finally closes the descriptor, so a mode-application failure leaked the fd. The mode call now sits inside that try. Tests: 'win32 DACL: when both restore attempts fail, the backup is retained' (rename happened, the .acl.tmp dump unlinked, no .bak unlink) and 'closes the staging descriptor when applying the target mode fails' (fchmodSync throws EPERM -> rejects, closeSync(7) called, no writeSync). Negative controls: dropping !daclRestoreFailed from the Step 6 condition -> 1 failed; moving the fchmod gate back outside the close-owning try -> 1 failed; restored -> 53 passed / 1 skipped. Local: safeWriteText.spec 53 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 42 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 31 +++++++++----- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index fa87dfeafc..afdd470f0b 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -443,6 +443,48 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) }) + it("closes the staging descriptor when applying the target mode fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(7) + vi.mocked(fsSync.fchmodSync).mockImplementation(() => { + throw Object.assign(new Error("EPERM"), { code: "EPERM" }) + }) + + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EPERM") + + // The descriptor is opened before the mode is applied, so the close has to live in the + // finally that also covers the mode call - otherwise a mode failure leaks the fd. + expect(fsSync.closeSync).toHaveBeenCalledWith(7) + expect(fsSync.writeSync).not.toHaveBeenCalled() + }) + + it("win32 DACL: when both restore attempts fail, the backup is retained", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // /save succeeds (call 1); both /restore attempts fail (calls 2 and 3). + 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", backup: true }) + + // The content is still committed - a failed ACL restore is not a write failure. + expect(fs.rename).toHaveBeenCalled() + // The dump is still cleaned up in the finally block. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + // But the backup survives: it is the only artifact that still carries the target's + // original security descriptor, so deleting it would destroy the recovery path. + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining(".bak")) + }) + it("win32 DACL: when target does not exist, no save/restore/dump", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 7247a63a33..b32a00a8e5 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -285,10 +285,10 @@ export async function safeWriteText(filePath: string, content: string, options?: // 0o664 target would be published as 0o644 and a shared repository would lose group // write access on every save. Apply the exact target mode on the descriptor, which is // also what the caller-staged branch below does. - if (targetExists) { - fsSync.fchmodSync(fd, 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. @@ -417,6 +417,11 @@ export async function safeWriteText(filePath: string, content: string, options?: } } + // Tracked separately from durability: a committed file whose DACL could not be + // restored is only half-published, and the backup is the only copy that still carries + // the original security descriptor. + let daclRestoreFailed = false + // -- Step 5 (win32): restore DACL AFTER commit rename --------- if (platform === "win32" && daclSaved && daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) @@ -427,17 +432,21 @@ export async function safeWriteText(filePath: string, content: string, options?: restored = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) } if (!restored) { - // Not fatal, for the same reason the save miss is not: on a non-elevated - // host /restore cannot succeed at all. The content is committed and the - // backup (when taken) is kept so the previous content and its permissions - // stay recoverable. + daclRestoreFailed = true + // Not fatal for the content: on a non-elevated host /restore cannot succeed at + // all, and the new content is already committed. It is fatal for the recovery + // state, so the backup is kept below and the situation is surfaced here. + console.warn( + `safeWriteText: ${targetPath} was published but its original DACL could not be restored from ${daclDumpPath ?? "the saved dump"}; the previous content and its permissions are only recoverable from the backup copy.`, + ) } } - // -- Step 6 (backup:true): delete backup on success ----------- - // Kept when durability is unconfirmed, so the previous content is still - // recoverable if the un-durable rename is lost on power loss. - if (backupCreated && backupPath && durabilityError === null) { + // -- Step 6 (backup:true): delete backup only on a fully successful publish -- + // Kept when durability is unconfirmed (the rename may be lost on power loss) AND when + // the DACL restore failed: that backup is the only artifact still carrying the target's + // original security descriptor, so deleting it would destroy the recovery path. + if (backupCreated && backupPath && durabilityError === null && !daclRestoreFailed) { try { await fs.unlink(backupPath) } catch { From 080e223d32fd43a9aad48242fb6457072c97a3db Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 03:22:08 +0800 Subject: [PATCH 22/60] fix(file-safety): remove a partially copied backup, and cover the failed backup cleanup Lifecycle: the backup destination was only tracked once copyFile resolved (backupCreated = true). A copyFile that creates the destination and then fails part way left that half-written file next to the target, because the failure cleanup was gated on backupCreated. The destination is now tracked as cleanup-required the moment it is chosen (backupAttempted), so the rollback removes it, and a failure to remove it is warned about with the path while the original error is still the one thrown. Regression Evidence: two focused tests - - 'keeps the publish and warns when both backup-removal attempts fail': both .bak unlinks reject, the publish still completes (rename happened), unlink was attempted exactly twice on the .bak path, and the warning names that path. - 'removes a partially copied backup when the copy itself fails': copyFile rejects with EACCES, the call rejects with EACCES, and the .bak path is unlinked. Negative controls: reverting the cleanup condition to backupCreated alone -> 1 failed (the new test); removing the second unlink attempt -> 3 failed (the new test plus two existing tests that pin the same retry); restored -> 55 passed / 1 skipped. Local: safeWriteText.spec 55 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 39 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 17 ++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index afdd470f0b..1be6cc16cc 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -443,6 +443,45 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) }) + it("keeps the publish and warns when both backup-removal attempts fail", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // Only the backup unlink fails, twice; every other unlink works. + const unlinkMock = vi.mocked(fs.unlink) + unlinkMock.mockImplementation(async (p) => { + if (typeof p === "string" && p.includes(".bak")) { throw new Error("EBUSY") } + return undefined + }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await safeWriteText(targetPath, "new data", { backup: true }) + + // The write itself is unaffected - the backup is cleanup, not part of the commit. + expect(fs.rename).toHaveBeenCalled() + const bakCalls = unlinkMock.mock.calls.filter(function (call) { + return typeof call[0] === "string" && call[0].includes(".bak") + }) + expect(bakCalls).toHaveLength(2) + const warned = warnSpy.mock.calls.map(function (call) { return String(call[0]) }).join("\n") + expect(warned).toContain("could not be removed") + expect(warned).toContain(".bak") + }) + + it("removes a partially copied backup when the copy itself fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // copyFile can create the destination and then fail part way through. + vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("EACCES") + + // The half-written backup must not be left next to the target, and the original error + // is still the one that surfaces. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".bak")) + }) + it("closes the staging descriptor when applying the target mode fails", 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 b32a00a8e5..f798507fe1 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -245,6 +245,10 @@ export async function safeWriteText(filePath: string, content: string, options?: let backupPath: string | null = null let backupCreated = false + // Set as soon as the backup destination is chosen, before the copy runs: a copyFile that + // fails part way can still leave a partial file at that path, and it has to be cleaned up + // like a completed backup would be. + let backupAttempted = false // Set when the commit landed but its durability could not be confirmed. The // content is in place, so there is nothing to roll back, but the caller must // not be told the publish is durable. @@ -371,6 +375,7 @@ export async function safeWriteText(filePath: string, content: string, options?: try { await fs.access(targetPath) backupPath = _tempName(dirPath, "safeWriteText.bak") + backupAttempted = true await fs.copyFile(targetPath, backupPath) backupCreated = true } catch (err: unknown) { @@ -485,12 +490,18 @@ export async function safeWriteText(filePath: string, content: string, options?: // tempPath is now the committed file; no cleanup needed. } catch (originalError: unknown) { // -- Rollback / cleanup on failure ---------------------------------- - if (backupCreated && backupPath) { + if ((backupCreated || backupAttempted) && backupPath) { + // Covers a copyFile that failed after creating the destination: the partial file + // would otherwise be orphaned next to the target. // The target was never moved, so rollback is just removing the backup copy. try { await fs.unlink(backupPath) - } catch { - // cleanup failure is non-fatal + } catch (cleanupError: unknown) { + // Cleanup failure is non-fatal, but an invisible orphan is worse than a visible one: + // the original error is still the one that gets thrown. + console.warn( + `safeWriteText: the write to ${targetPath} failed and its backup copy at ${backupPath} could not be removed (${String(cleanupError)}).`, + ) } } From 1ee3607cfa850f9d835909df462c69fac0d6f58d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 04:32:10 +0800 Subject: [PATCH 23/60] fix(file-safety): fail closed when a Windows target's DACL cannot be captured Security Boundaries / Persistence Integrity: safeWriteText publishes by renaming a staged file over the target, which replaces the target's security descriptor with whatever the parent directory inherits. The previous code treated a failed icacls /save as non-fatal and published anyway, leaving no record to restore the original DACL from - the post-rename /restore could then only guess. Publishing now aborts before the commit rename when no usable dump exists (DaclCaptureError), and the rollback cleans up the staging file, any partial backup and the dump. The capture is judged by the artifact rather than the exit code: icacls /save can exit non-zero while still writing a usable dump (measured on a normal host), so a non-empty dump counts as a capture and the publish proceeds. /save does not need the backup/restore privileges that /restore does (measured: /restore exits 1300 un-elevated), so failing closed here does not break ordinary hosts - it removes the exposure instead of accepting it. Tests: 'win32 DACL capture failure aborts before publishing' (rejects, no rename, only the save ran, dump and staging unlinked); 'removes the DACL dump and aborts when the save fails after creating it'; 'win32 DACL: a non-zero icacls exit that still wrote a dump counts as a capture' (publish proceeds, a /restore is attempted). Negative controls: removing the throw -> exactly 2 failed (the two abort tests); replacing the artifact check with a plain false -> exactly 1 failed (the non-zero-exit test); restored -> 56 passed / 1 skipped. Local: safeWriteText.spec 56 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 47 ++++++++++++---- src/services/file-safety/safeWriteText.ts | 56 +++++++++++++------ 2 files changed, 73 insertions(+), 30 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 1be6cc16cc..4fe539dac3 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -349,26 +349,48 @@ describe("safeWriteText", () => { expect(execFile).not.toHaveBeenCalled() }) - it("win32 DACL failure falls back to plain rename (never fails the write)", async () => { + it("win32 DACL capture failure aborts before publishing", 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. + // icacls dump fails and no usable dump exists. 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" }) + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refusing to publish/) - // write succeeded despite icacls failure (fallback to plain rename) - expect(fs.rename).toHaveBeenCalled() - // Only the save ran: a failed save must not be followed by a restore attempt. + // Fail closed: the target is never published, because publishing would replace its security + // descriptor with inherited permissions and nothing could put the original back. + expect(fs.rename).not.toHaveBeenCalled() + // Only the save ran: a failed capture must not be followed by a restore attempt. expect(execFile).toHaveBeenCalledTimes(1) - // The dump the failed save may have created is still cleaned up, so nothing is left behind. + // The staging file and any partial dump are still cleaned up by the rollback. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl.tmp")) - // The write itself is unaffected: plain rename of the staged file onto the target. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("win32 DACL: a non-zero icacls exit that still wrote a dump counts as a capture", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // Measured on a normal host: /save can exit non-zero while leaving a usable dump behind. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls exit 1"), "", "") + return fakeChild + }) + vi.mocked(fsSync.statSync).mockReturnValue({ size: 256 } as never) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // The capture is judged by the artifact, so the publish proceeds and the restore is attempted. expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + // Save, then the restore attempt (icacls is retried once if it fails, so >= 2). + const restoreCalls = vi.mocked(execFile).mock.calls.filter(function (call) { + return Array.isArray(call[1]) && String(call[1]).includes("/restore") + }) + expect(restoreCalls.length).toBeGreaterThan(0) }) it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { @@ -1126,20 +1148,21 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) }) - it("removes the DACL dump when the save fails after creating it", async () => { + it("removes the DACL dump and aborts when the save fails after creating it", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) // execFile callback form: report a failure after the dump file was created. - vi.mocked(execFile).mockImplementationOnce(((_cmd: string, _args: string[], _opts: unknown, cb: (e: Error | null) => void) => { + vi.mocked(execFile).mockImplementationOnce(((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => { cb(new Error("icacl dump failed")) return fakeChild }) as unknown as typeof execFile) - await safeWriteText(targetPath, "hello world", { platform: "win32" }) + await expect(safeWriteText(targetPath, "hello world", { platform: "win32" })).rejects.toThrow(/refusing to publish/) - // The dump path stays tracked so the finally block removes the file the failed save created. + // The dump path stays tracked so the rollback removes the file the failed save created. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl.tmp")) + expect(fs.rename).not.toHaveBeenCalled() }) }) }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index f798507fe1..5da4e3edf8 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -66,6 +66,21 @@ class UnsafeStagingDirectoryError extends Error { * The new content is in place; what is unconfirmed is that the directory entry * survives a crash, so callers must not treat the publish as durable. */ +/** + * The target exists but its DACL could not be captured, so publishing would replace its security + * descriptor with inherited permissions and no record would exist to restore it from. Thrown + * before the commit rename; the target is left untouched. + */ +export class DaclCaptureError extends Error { + constructor( + public readonly targetPath: string, + public readonly dumpPath: string, + ) { + super(`safeWriteText: refusing to publish ${targetPath}: its DACL could not be captured (no icacls dump was written at ${dumpPath}). No change was made to the target.`) + this.name = "DaclCaptureError" + } +} + export class PublishNotDurableError extends Error { constructor(targetPath: string, reason: string) { super(`Published ${targetPath} but could not confirm it is durable: ${reason}`) @@ -127,9 +142,10 @@ 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". */ + /** Save the DACL of *srcPath* to a dump file on Windows. + * Returns true when a usable dump exists; false otherwise. + * Never throws - a false return means no usable dump was produced, which the caller treats + * as "the security descriptor cannot be preserved" and fails closed before publishing. */ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { const runner = execFileRunner ?? execFile try { @@ -140,7 +156,13 @@ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunne }) return true } catch { - return false + // icacls can exit non-zero while still writing a usable dump (measured on a normal host), so + // judge the capture by the artifact, not only by the exit code: a non-empty dump is a capture. + try { + return fsSync.statSync(dumpPath).size > 0 + } catch { + return false + } } } @@ -343,24 +365,22 @@ export async function safeWriteText(filePath: string, content: string, options?: daclDumpPath = _tempName(dirPath, "safeWriteText.acl.tmp") const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) if (!saved) { - // Skip the restore step, but keep daclDumpPath tracked: a failed save can - // still have created the dump file, and the finally block must remove it. - // - // This is deliberately NOT fatal. icacls /restore needs backup/restore - // privileges: measured on a normal (non-elevated) Windows host it fails with - // "Not all privileges or groups referenced are assigned to the caller" - // (exit 1300), and /save itself can exit non-zero while still writing a - // usable dump. Refusing the write would therefore break every save on such a - // host without removing the exposure. The privilege-free fix - copying the - // target's DACL onto the staged file BEFORE the commit, so the rename never - // changes the security descriptor - is tracked separately; see the note on - // tracking issue easonLiangWorldedtech/Zoo-Code#41. + // Fail closed. Publishing without a captured DACL replaces the target's security + // descriptor with whatever the parent directory inherits, and no later step can put the + // original back. icacls /save does not need the backup/restore privileges that /restore + // does (measured: /restore exits 1300 un-elevated), so refusing here does not break + // ordinary hosts - it removes the exposure instead of accepting it. The staging file, + // any partial backup and the dump are cleaned by the rollback below. daclSaved = false + throw new DaclCaptureError(targetPath, daclDumpPath) } else { daclSaved = true } - } catch { - // target does not exist or access failed — no DACL handling + } catch (err: unknown) { + if (err instanceof DaclCaptureError) { + // Not an access probe failure: the target exists and its DACL could not be captured. + throw err + } daclDumpPath = null } } From 6a903279e954999e02d06c18cf4d5b9b419d25c9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 04:41:45 +0800 Subject: [PATCH 24/60] fix(file-safety): propagate a failed lstat in resolvePublishTarget, and cover the dump cleanup resolvePublishTarget caught every lstat error after a realpath ENOENT and treated it as "not a symlink", so an EACCES or EIO on the link path fell back to publishing onto the link path and replaced the link - the exact failure the resolver exists to prevent, and the opposite of what its own doc comment promises ("any other resolution error propagates"). Only ENOENT now falls back; any other error propagates. Regression Evidence coverage added for the paths the checklist named: - resolvePublishTarget: 'propagates an lstat failure that follows a realpath ENOENT' and 'propagates a readlink failure for a dangling symlink'. - DACL dump cleanup: 'removes the DACL dump on the second attempt when the first unlink fails' and 'warns with the retained dump path when both dump unlinks fail' (an icacls dump is a readable copy of the target's ACL, so an invisible orphan is worse than a visible one). Negative controls (blast radius as measured): restoring the fail-open -> exactly 1 failed (the new resolver test); removing the second dump unlink -> the dump tests fail; restored -> 60 passed / 1 skipped. The 'readlink not called' assertion clears the shared stub first, because earlier tests in this file leave a readlink mock installed and this describe does not reset it. Local: safeWriteText.spec 60 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 70 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 13 +++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 4fe539dac3..c276c1ff6a 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -465,6 +465,51 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) }) + it("removes the DACL dump on the second attempt when the first unlink fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + let dumpUnlinks = 0 + const unlinkMock = vi.mocked(fs.unlink) + unlinkMock.mockImplementation(async (p) => { + if (typeof p === "string" && p.includes(".acl.tmp")) { + dumpUnlinks++ + if (dumpUnlinks === 1) { throw new Error("EBUSY") } + } + return undefined + }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // One bounded retry is enough here, and nothing is surfaced because the retry succeeded. + expect(dumpUnlinks).toBe(2) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it("warns with the retained dump path when both dump unlinks fail", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const unlinkMock = vi.mocked(fs.unlink) + unlinkMock.mockImplementation(async (p) => { + if (typeof p === "string" && p.includes(".acl.tmp")) { throw new Error("EBUSY") } + return undefined + }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // An icacls dump is a readable copy of the target's ACL: leaving it behind is acceptable, + // leaving it behind invisibly is not. + const dumpCalls = unlinkMock.mock.calls.filter(function (call) { + return typeof call[0] === "string" && call[0].includes(".acl.tmp") + }) + expect(dumpCalls).toHaveLength(2) + const warned = warnSpy.mock.calls.map(function (call) { return String(call[0]) }).join("\n") + expect(warned).toContain(".acl.tmp") + }) + it("keeps the publish and warns when both backup-removal attempts fail", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -1210,4 +1255,29 @@ describe("resolvePublishTarget symlink cycle (S1)", () => { expect(vi.mocked(fs.rename)).not.toHaveBeenCalled() expect(vi.mocked(fsSync.openSync)).not.toHaveBeenCalled() }) + + it("propagates an lstat failure that follows a realpath ENOENT", async () => { + const linkPath = "/tmp/test-dir/dangling-link" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fs.lstat).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + // readlink is stubbed by earlier tests in this file and this describe does not reset it, + // so clear it here to make "never called" mean what it says. + vi.mocked(fs.readlink).mockClear() + + // "Could not inspect" is not "not a link": falling back to the link path here would let the + // publish replace the link, which is what the resolver exists to prevent. + await expect(resolvePublishTarget(linkPath)).rejects.toThrow("EACCES") + expect(fs.readlink).not.toHaveBeenCalled() + }) + + it("propagates a readlink failure for a dangling symlink", async () => { + const linkPath = "/tmp/test-dir/dangling-link" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fs.lstat).mockResolvedValue({ isSymbolicLink: () => true } as never) + vi.mocked(fs.readlink).mockRejectedValue(Object.assign(new Error("EIO"), { code: "EIO" })) + + // The link is known to exist but its target cannot be read: publishing onto the link path + // would replace the link with a regular file, so the error has to surface. + await expect(resolvePublishTarget(linkPath)).rejects.toThrow("EIO") + }) }) \ No newline at end of file diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 5da4e3edf8..58974f2c45 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -221,7 +221,18 @@ export async function resolvePublishTarget( // Renaming onto the link path would replace the link with a regular file, so follow // the link text to its referent instead; a link cycle surfaces as ELOOP and still // propagates, so the recursion is bounded. - const link = await fs.lstat(absoluteFilePath).catch(() => null) + // Only ENOENT means "there is nothing here that could be a link". Any other lstat error + // (EACCES, EIO, ...) must propagate: treating "could not inspect" as "not a link" would let + // a write land on the link path and replace the link, which is exactly what this resolver + // exists to prevent - and the doc comment above already promises that it propagates. + let link: Awaited> | null = null + try { + link = await fs.lstat(absoluteFilePath) + } catch (statError: unknown) { + if (_errorCode(statError) !== "ENOENT") { + throw statError + } + } if (link?.isSymbolicLink()) { const linkText = await fs.readlink(absoluteFilePath) if (visitedLinks.has(absoluteFilePath)) { From 62bf8db7bb1fd532b26ffc3445cbe41abc6100ed Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:32:32 +0800 Subject: [PATCH 25/60] fix(file-safety): judge the DACL capture by its artifact on both callbacks, and tolerate only ENOENT in the probe Persistence Integrity: _saveDaclWindows returned true as soon as the icacls callback reported success, without looking at the dump. icacls can exit 0 and write nothing (measured when the target's owner differs from the caller), and a zero-byte dump restores nothing, so trusting the exit code let the commit rename destroy a security descriptor that was never captured. Both callbacks are now judged by _dumpIsUsable: the dump must exist, be a regular file, and have bytes. The target-existence probe now tolerates only ENOENT. EACCES or a code-less error says nothing about the target's descriptor, and publishing over a target we could not inspect would replace it without a captured DACL. Tests: 'fails closed when icacls reports success but wrote no dump'; an it.each over EACCES and a code-less error for the probe; 'removes the staged file and the dump when the DACL capture aborts'; 'reports the retained artifact when the abort cleanup cannot remove it'. The win32 DACL and backup:true describes now stub fsSync.statSync to produce a usable dump, because the capture is judged by its artifact and the mock world has to supply one. Negative controls, as measured: trusting the exit code again -> 3 failed (the fail-closed test plus the two abort tests that depend on the capture failing); removing the abort-path cleanup -> 1 failed (the retained-artifact warning). NOT YET LOAD-BEARING: swallowing the probe error leaves all 65 tests green, so the two probe tests do not yet pin the propagation - the code fix is correct by inspection, but the test needs a mock that isolates the DACL step's probe call. Recorded rather than claimed. Local: safeWriteText.spec 65 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 81 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 48 ++++++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index c276c1ff6a..f2a29cdedf 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -249,6 +249,11 @@ describe("safeWriteText", () => { // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── describe("backup:true", () => { + // These tests exercise the win32 publish path, whose DACL capture is judged by its + // artifact, so the mock world has to produce a usable dump. + beforeEach(() => { + vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 256 })) as never) + }) it("copies target -> backup before commit, deletes backup on success", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -312,6 +317,11 @@ describe("safeWriteText", () => { // ── Test 5: win32 DACL path ────────────────────────────────────────────── describe("win32 DACL", () => { + // The DACL capture is judged by its artifact, so the mock world has to produce one: + // a regular, non-empty dump. Tests that want an unusable artifact override this. + beforeEach(() => { + vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 256 })) as never) + }) it.skipIf(process.platform !== "win32")( "copies target DACL onto staging file via icacls before rename on Windows", async () => { @@ -349,7 +359,76 @@ describe("safeWriteText", () => { expect(execFile).not.toHaveBeenCalled() }) + it("fails closed when icacls reports success but wrote no dump", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls exits 0 without producing the dump (measured when the target's owner differs + // from the caller). The exit code alone is not evidence that a descriptor was captured. + vi.mocked(execFile).mockImplementation(((_c: string, _a: string[], _o: unknown, cb?: (e?: Error | null) => void) => { cb?.(null); return undefined }) as never) + vi.mocked(fsSync.statSync).mockImplementation((() => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) }) as never) + + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refusing to publish/) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it.each([["EACCES", "EACCES"], ["a code-less probe error", undefined]])("propagates a %s target-existence probe instead of treating the target as absent", async (_label, code) => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // An unreadable target is not an absent target: publishing over it would replace a file + // whose security descriptor was never captured. + // Call-order aware: only the DACL step's probe fails. A later fs.access in the publish + // path succeeds, so a swallowed probe error would let the write continue - which is what + // makes this assertion load-bearing rather than accidentally satisfied by a later failure. + let probeCalls = 0 + vi.mocked(fs.access).mockImplementation((async () => { + probeCalls++ + if (probeCalls === 1) { throw Object.assign(new Error("probe failed"), code ? { code } : {}) } + return undefined + }) as never) + + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow("probe failed") + expect(fs.rename).not.toHaveBeenCalled() + expect(execFile).not.toHaveBeenCalled() + // Nothing downstream of the probe may run: a swallowed probe error would keep going and + // copy the target to a backup, which is what makes this assertion load-bearing. + expect(fs.copyFile).not.toHaveBeenCalled() + }) + + it("removes the staged file and the dump when the DACL capture aborts", 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" }) }) as never) + const unlinkMock = vi.mocked(fs.unlink) + + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refusing to publish/) + + // The abort leaves the try/finally that owns cleanup, so the artifacts are released on the + // abort path itself: neither the dump nor the staged file may be orphaned. + const unlinked = unlinkMock.mock.calls.map(function (call) { return String(call[0]) }) + expect(unlinked.some(function (p) { return p.includes(".acl.tmp") })).toBe(true) + expect(unlinked.some(function (p) { return p.includes("safeWriteText_") })).toBe(true) + }) + + it("reports the retained artifact when the abort cleanup cannot remove it", 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" }) }) as never) + vi.mocked(fs.unlink).mockRejectedValue(new Error("EBUSY")) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refusing to publish/) + + const warned = warnSpy.mock.calls.map(function (call) { return String(call[0]) }).join("\n") + expect(warned).toContain("could not remove") + }) + it("win32 DACL capture failure aborts before publishing", async () => { + // The capture is judged by the artifact: make the dump absent so the capture fails. + vi.mocked(fsSync.statSync).mockImplementation((() => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) }) as never) const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -380,7 +459,7 @@ describe("safeWriteText", () => { if (typeof cb === "function") cb(new Error("icacls exit 1"), "", "") return fakeChild }) - vi.mocked(fsSync.statSync).mockReturnValue({ size: 256 } as never) + vi.mocked(fsSync.statSync).mockReturnValue({ isFile: () => true, size: 256 } as never) await safeWriteText(targetPath, "data", { platform: "win32" }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 58974f2c45..4f0071d149 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -154,18 +154,32 @@ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunne err ? reject(err) : resolve(), ) }) - return true + return _dumpIsUsable(dumpPath) } catch { // icacls can exit non-zero while still writing a usable dump (measured on a normal host), so // judge the capture by the artifact, not only by the exit code: a non-empty dump is a capture. try { - return fsSync.statSync(dumpPath).size > 0 + return _dumpIsUsable(dumpPath) } catch { return false } } } +// A DACL dump is only usable if it is actually there, is a regular file, and has bytes in it. +// Both the success and the failure callback have to be judged by this: icacls can exit 0 without +// writing anything (measured on a host where the target's owner differs from the caller), and a +// zero-byte dump restores nothing - so trusting the exit code alone would let the commit rename +// destroy a security descriptor that was never captured. +function _dumpIsUsable(dumpPath: string): boolean { + try { + const st = fsSync.statSync(dumpPath) + return st.isFile() && st.size > 0 + } catch { + return false + } +} + /** Restore a DACL dump onto *dirPath* on Windows. * Returns true when icacls reported success; false otherwise, so the caller can * decide whether a lost DACL is tolerable. */ @@ -383,6 +397,28 @@ export async function safeWriteText(filePath: string, content: string, options?: // ordinary hosts - it removes the exposure instead of accepting it. The staging file, // any partial backup and the dump are cleaned by the rollback below. daclSaved = false + // This throw leaves the try/finally that owns cleanup, so release the artifacts here: + // the staged file and the (unusable) dump must not be orphaned. Removal is retried once + // and a retained path is reported rather than suppressed. + const abortArtifacts: Array = [ + daclDumpPath, + options?.tempPath ? null : tempPath, // a caller-supplied temp belongs to the caller + ] + for (const artifact of abortArtifacts) { + if (!artifact) { continue } + let removed = false + for (let attempt = 0; attempt < 2 && !removed; attempt++) { + try { + await fs.unlink(artifact) + removed = true + } catch { + // retry once, then report + } + } + if (!removed) { + console.warn(`safeWriteText: aborted before publishing and could not remove ${artifact}; it is retained and must be cleaned up out of band.`) + } + } throw new DaclCaptureError(targetPath, daclDumpPath) } else { daclSaved = true @@ -392,6 +428,14 @@ export async function safeWriteText(filePath: string, content: string, options?: // Not an access probe failure: the target exists and its DACL could not be captured. throw err } + + // Only ENOENT means "there is no target, so there is no DACL to preserve". EACCES or any + // other probe error says nothing about the target's security descriptor, and publishing + // over a target we could not inspect would replace it without a captured DACL. + const probeCode = err && typeof err === "object" && "code" in err ? (err as { code?: string }).code : undefined + if (probeCode !== "ENOENT") { + throw err + } daclDumpPath = null } } From b19940ffd2acc8d9cf69531116846d27a4d6a0d4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:36:17 +0800 Subject: [PATCH 26/60] test(file-safety): make the target-probe propagation tests load-bearing The two probe tests mocked fs.access by CALL ORDER (fail the first call), but fs.access is also called for the parent directory earlier in the flow (safeWriteText.ts:273) before the DACL step probes the target (:385). The first call was therefore the directory check, whose error propagates on its own - so the tests passed whether or not the DACL probe propagated, and the negative control (swallowing the probe error) showed no failures at all. The mock is now path-aware: only the probe of the target itself fails. Negative control re-measured: swallowing the probe error -> exactly 2 failed (both it.each variants); restored -> 65 passed / 1 skipped. Same lesson as the lstat/realpath mocks in this chain: when a helper calls the same fs function for several paths, script by path, never by call order. Local: safeWriteText.spec 65 passed / 1 skipped; src-level tsc --noEmit 0 (re-run after the last edit); eslint 0 err / 0 warn. --- src/services/file-safety/__tests__/safeWriteText.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index f2a29cdedf..3107437f1c 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -381,10 +381,11 @@ describe("safeWriteText", () => { // Call-order aware: only the DACL step's probe fails. A later fs.access in the publish // path succeeds, so a swallowed probe error would let the write continue - which is what // makes this assertion load-bearing rather than accidentally satisfied by a later failure. - let probeCalls = 0 - vi.mocked(fs.access).mockImplementation((async () => { - probeCalls++ - if (probeCalls === 1) { throw Object.assign(new Error("probe failed"), code ? { code } : {}) } + // Path-aware, not call-order: fs.access is also called for the parent directory earlier in + // the flow, so a first-call mock fails the WRONG probe and the assertion is satisfied by + // accident. Only the probe of the target itself may fail here. + vi.mocked(fs.access).mockImplementation((async (p: string) => { + if (p === targetPath) { throw Object.assign(new Error("probe failed"), code ? { code } : {}) } return undefined }) as never) From f12b20724330b43f2f765af5b4fc53ada4f55685 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:57:31 +0800 Subject: [PATCH 27/60] fix(file-safety): release a staging directory this call created, and pin the W_OK guard argument Lifecycle: _stagingDir validated the directory AFTER creating it (re-check for a symlink swap, then ownership). When that validation rejected, the directory this call had just created was left behind - the caller never receives the path, so nothing else can clean it up and a stray .file-safety-staging sits in the user's target directory. _abandonIfCreated() now removes it on both rejection paths, and only when this call created it: a pre-existing staging directory may belong to a concurrent write, so it is left alone. Regression Evidence: saveDirectly's write-permission guard calls fs.access(absolutePath, W_OK), but the focused test only asserted the path argument. An existence-only check would not catch a read-only target, which is the exact contract this unit preserves, so the test now asserts the second argument is fsConstants.W_OK. Negative controls, as measured: removing both _abandonIfCreated() calls -> exactly 1 failed (the new 'removes a staging directory it created' test; the companion 'leaves a pre-existing staging directory' test stays green because it asserts the negative). Dropping the W_OK flag from the guard -> exactly 1 failed (the saveDirectly permission test). Local: safeWriteText.spec 67 passed / 1 skipped; DiffViewProvider.spec 74 passed; src-level tsc --noEmit 0 (re-run after the final edit); eslint 0 err / 0 warn on all three files; eslint-suppressions.json untouched. --- .../editor/__tests__/DiffViewProvider.spec.ts | 5 +++ .../__tests__/safeWriteText.spec.ts | 31 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 17 ++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index f46b95db01..2c189b931b 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -824,6 +824,11 @@ describe("DiffViewProvider", () => { expect(accessIndex).toBeGreaterThanOrEqual(0) expect(calls[accessIndex][0]).toBe(`${mockCwd}/test.ts`) + // An existence-only check would not catch a read-only target: the guard has to be handed + // the write bit, otherwise the read-only contract this unit preserves is silently gone. + const { constants } = await import("fs") + expect(calls[accessIndex][1]).toBe(constants.W_OK) + // The check is only meaningful before the publish; after it, an EACCES target would // already have been renamed over. expect(vi.mocked(fs.access).mock.invocationCallOrder[accessIndex]).toBeLessThan( diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 3107437f1c..b9513af9d8 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -42,6 +42,7 @@ vi.mock("fs", () => ({ fchmodSync: vi.fn(), statSync: vi.fn(), lstatSync: vi.fn(), + rmdirSync: vi.fn(), Stats: class Stats {}, })) @@ -1169,6 +1170,36 @@ describe("safeWriteText", () => { expect(fs.rename).not.toHaveBeenCalled() }) + it("removes a staging directory it created when the post-create re-check rejects it", 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" }) + // Guard: nothing there, so this call is the one that creates it. Re-check: swapped for a + // symlink. The caller never receives the path, so this call is also the only one that can + // clean it up - otherwise a stray .file-safety-staging sits in the user's directory. + vi.mocked(fsSync.lstatSync) + .mockImplementationOnce(() => { + throw enoent + }) + .mockReturnValueOnce(_linkStats()) + + const name = await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" })) + expect(name).toBe("UnsafeStagingDirectoryError") + expect(fsSync.rmdirSync).toHaveBeenCalledWith(_stagingDir(path.dirname(targetPath))) + }) + + it("leaves a pre-existing staging directory when the post-create re-check rejects it", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // Guard: the directory was already there - it may belong to a concurrent write, so this + // call must not remove it even though the re-check rejects the path. + vi.mocked(fsSync.lstatSync).mockReturnValueOnce(_dirStats()).mockReturnValueOnce(_linkStats()) + + const name = await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" })) + expect(name).toBe("UnsafeStagingDirectoryError") + expect(fsSync.rmdirSync).not.toHaveBeenCalled() + }) + it("does not retry a rename failure on a platform without sharing violations", 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 4f0071d149..f8253614a0 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -102,8 +102,10 @@ function _stagingDir(dir: string): string { // symlink planted by another process would stage - and then publish - outside // the target directory. Anything that is not a real directory owned by this // process is rejected instead of used. + let preExisted = false try { const existing = fsSync.lstatSync(sd) + preExisted = true if (!existing.isDirectory()) { throw new UnsafeStagingDirectoryError(sd, "it exists and is not a directory") } @@ -111,6 +113,19 @@ function _stagingDir(dir: string): string { if (error instanceof UnsafeStagingDirectoryError) throw error if (_errorCode(error) !== "ENOENT") throw error } + // A directory this call created but never hands to the caller would sit in the user's + // target directory forever - the caller never receives the path, so nothing else can + // clean it up. A pre-existing staging directory is left alone: it may belong to a + // concurrent write. + function _abandonIfCreated(): void { + if (!preExisted) { + try { + fsSync.rmdirSync(sd) + } catch { + // not empty or already gone: there is nothing else this call can do + } + } + } // 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. @@ -119,9 +134,11 @@ function _stagingDir(dir: string): string { // check above and mkdirSync. const created = fsSync.lstatSync(sd) if (!created.isDirectory()) { + _abandonIfCreated() throw new UnsafeStagingDirectoryError(sd, "it was replaced by a non-directory") } if (typeof process.getuid === "function" && created.uid !== process.getuid()) { + _abandonIfCreated() throw new UnsafeStagingDirectoryError(sd, "it is not owned by this process") } try { From f0a3a759af0b875adf40fa57945695c79bb54e21 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 06:51:46 +0800 Subject: [PATCH 28/60] test(file-safety): cover the parent-directory create/verify failures in safeWriteText Regression Evidence: safeWriteText.ts:289-290 creates and then verifies the parent directory, but every test in the file lets fs.mkdir and fs.access resolve, so neither failure path was exercised. Added: mkdir rejecting EACCES propagates and no staging/open/publish runs; access rejecting EACCES on the parent propagates the same way; and the successful path asserts mkdir(dir, {recursive:true}) then access(dir) then the publish rename. Negative control, as measured: swallowing both failures (.catch(() => undefined)) -> exactly those 2 failed; restored -> 70 passed / 1 skipped. Local: safeWriteText.spec 70 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. NOT PUSHED: held locally so the next push carries this plus the partial-backup ENOENT fix (same checklist round), so one review slot covers both. --- .../__tests__/safeWriteText.spec.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index b9513af9d8..a0987181fe 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1391,4 +1391,46 @@ describe("resolvePublishTarget symlink cycle (S1)", () => { // would replace the link with a regular file, so the error has to surface. await expect(resolvePublishTarget(linkPath)).rejects.toThrow("EIO") }) +}) + +describe("parent directory creation (safeWriteText.ts:288-290)", () => { + beforeEach(() => { + vi.mocked(fs.realpath).mockResolvedValue("/tmp/test-dir/target.txt") + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fsSync.openSync).mockReturnValue(1) + }) + + it("propagates an mkdir failure before any staging, open or publish", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const err = Object.assign(new Error("EACCES: cannot create"), { code: "EACCES" }) + vi.mocked(fs.mkdir).mockRejectedValue(err) + + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(err) + + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("propagates an access failure on the parent directory before any staging, open or publish", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const err = Object.assign(new Error("EACCES: cannot verify"), { code: "EACCES" }) + vi.mocked(fs.access).mockRejectedValue(err) + + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(err) + + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("creates and verifies the parent directory on the successful path", async () => { + const targetPath = "/tmp/test-dir/nested/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + expect(fs.mkdir).toHaveBeenCalledWith("/tmp/test-dir/nested", { recursive: true }) + expect(fs.access).toHaveBeenCalledWith("/tmp/test-dir/nested") + expect(fs.rename).toHaveBeenCalled() + }) }) \ No newline at end of file From b1efbe19b2d3a59e8406e67462cebd94c4bcaffb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 06:56:18 +0800 Subject: [PATCH 29/60] fix(file-safety): remove a partial backup after a successful publish, and cover the parent-dir failures Lifecycle: the backup step sets backupPath and backupAttempted before fs.copyFile runs. A copy that fails part way can leave a partial file at that path with backupCreated never set, and the copy's ENOENT is swallowed so the publish continues. The success-path cleanup was gated on backupCreated, so that half-written backup survived a successful publish forever - nothing else in the flow ever touches it. The condition now matches the failure path's (backupCreated || backupAttempted), reusing the existing bounded retry and the warning that surfaces a path it cannot remove. Regression Evidence (same checklist round, committed at 568f981c5): safeWriteText.ts:289-290 creates and verifies the parent directory, but every test let fs.mkdir and fs.access resolve, so neither failure path was exercised. Negative controls, as measured: reverting the cleanup condition to backupCreated only -> exactly 1 failed (the new partial-backup test); swallowing mkdir and access -> exactly 2 failed (the parent-dir tests). Local: safeWriteText.spec 71 passed / 1 skipped; src-level tsc --noEmit 0 (re-run after the final edit); eslint 0 err / 0 warn on both files; eslint-suppressions.json untouched. --- .../__tests__/safeWriteText.spec.ts | 18 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index a0987181fe..3331f21833 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1433,4 +1433,22 @@ describe("parent directory creation (safeWriteText.ts:288-290)", () => { expect(fs.access).toHaveBeenCalledWith("/tmp/test-dir/nested") expect(fs.rename).toHaveBeenCalled() }) +}) + +describe("partial backup after a failed copy (backup:true)", () => { + it("removes the partial backup when copyFile fails with ENOENT and the publish succeeds", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The target exists for the backup pre-check, then the copy itself fails: a partial file + // may already sit at the .bak path while backupCreated stays false. The publish still + // succeeds, and nothing else in the flow would ever remove that half-written backup. + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + + await safeWriteText(targetPath, "data", { platform: "linux", backup: true }) + + expect(fs.rename).toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak")) + }) }) \ No newline at end of file diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index f8253614a0..a730b7c6bf 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -543,7 +543,10 @@ export async function safeWriteText(filePath: string, content: string, options?: // Kept when durability is unconfirmed (the rename may be lost on power loss) AND when // the DACL restore failed: that backup is the only artifact still carrying the target's // original security descriptor, so deleting it would destroy the recovery path. - if (backupCreated && backupPath && durabilityError === null && !daclRestoreFailed) { + // A copy that failed part way leaves a partial file at backupPath with backupCreated + // never set, so the condition has to cover backupAttempted too: after a successful + // publish nothing else would ever remove that half-written backup. + if ((backupCreated || backupAttempted) && backupPath && durabilityError === null && !daclRestoreFailed) { try { await fs.unlink(backupPath) } catch { From 0903d24dd8c786d48097ce4b600ac0c57e3c3bbe Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 07:25:17 +0800 Subject: [PATCH 30/60] test(file-safety): cover the zero-byte icacls dump in the win32 DACL capture Regression Evidence: _dumpIsUsable accepts an artifact only when it is a regular file with size > 0 (safeWriteText.ts:186-197). The suite covered an absent dump and a non-empty dump, so the empty-file case - icacls creating the file and writing nothing into it - was unexercised, and a zero-byte dump would restore a security descriptor that carries no data. Added a win32 test where icacls exits 0 and statSync reports a regular dump with size 0: safeWriteText must refuse to publish and must not reach the commit rename. Negative control, as measured: relaxing _dumpIsUsable to st.isFile() alone -> exactly 1 failed (this test). Production restored byte-identical. Local: safeWriteText.spec 72 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../file-safety/__tests__/safeWriteText.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 3331f21833..8357f5afe2 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -373,6 +373,19 @@ describe("safeWriteText", () => { expect(fs.rename).not.toHaveBeenCalled() }) + it("fails closed when the dump is a regular file but empty", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // A zero-byte dump is not a captured security descriptor: icacls can create the file and + // write nothing into it. Accepting it would publish with a restore that carries no data. + vi.mocked(execFile).mockImplementation(((_c: string, _a: string[], _o: unknown, cb?: (e?: Error | null) => void) => { cb?.(null); return undefined }) as never) + vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 0 })) as never) + + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refus/) + expect(fs.rename).not.toHaveBeenCalled() + }) + it.each([["EACCES", "EACCES"], ["a code-less probe error", undefined]])("propagates a %s target-existence probe instead of treating the target as absent", async (_label, code) => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) From faaf143fa1c0bd8fd5a0dc3bdba24baaac79b477 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 08:27:43 +0800 Subject: [PATCH 31/60] fix(file-safety): classify staging-directory release errors instead of swallowing them Lifecycle: _releaseStagingDir caught every error from fs.rmdir and said nothing, so a staging directory left behind by a transient failure was invisible forever. ENOENT and ENOTEMPTY are benign and stay silent: the first means another writer already removed it, the second means a concurrent write is still staging here, which is the concurrency guard the directory exists for. Anything else (EBUSY, EPERM, EACCES, ...) is transient: retry once, then warn with the path and the errno. A leftover staging directory is acceptable, an invisible one is not. Negative controls, as measured - three separate mutations, each turning exactly one test red: swallow every rmdir error again -> 1 failed (the retry-and-surface test) make ENOTEMPTY non-benign -> 1 failed (the benign test) suppress the post-retry warning -> 1 failed (the retry-and-surface test) Production restored byte-identical after each. How this differs from the attempt I reverted earlier: that version asserted a loose substring that an unrelated warning in the flow already satisfied, so its controls stayed green. This version first proved with a probe that fs.rmdir really is called with the staging path during a successful write, then asserts the exact message this code emits. Local: safeWriteText.spec 74 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 27 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 21 ++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8357f5afe2..e579f6c7f8 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1464,4 +1464,31 @@ describe("partial backup after a failed copy (backup:true)", () => { expect(fs.rename).toHaveBeenCalled() expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak")) }) +}) + +describe("staging directory release", () => { + it("retries a transient release failure once and then surfaces it", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.mocked(fs.realpath).mockResolvedValue("/tmp/test-dir/target.txt") + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rmdir).mockRejectedValue(Object.assign(new Error("EBUSY"), { code: "EBUSY" })) + + await safeWriteText("/tmp/test-dir/target.txt", "data", { platform: "linux" }) + + const messages = warn.mock.calls.map(function (c) { return String(c[0]) }) + expect(messages.filter(function (m) { return m.indexOf("staging directory release failed") >= 0 }).length).toBe(1) + warn.mockRestore() + }) + + it("treats ENOTEMPTY as benign: no warning at all", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.mocked(fs.realpath).mockResolvedValue("/tmp/test-dir/target.txt") + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rmdir).mockRejectedValue(Object.assign(new Error("ENOTEMPTY"), { code: "ENOTEMPTY" })) + + await safeWriteText("/tmp/test-dir/target.txt", "data", { platform: "linux" }) + + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) }) \ No newline at end of file diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index a730b7c6bf..7df22009f3 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -300,10 +300,23 @@ export async function safeWriteText(filePath: string, content: string, options?: // still stages here, which is exactly the concurrency guard wanted. async function _releaseStagingDir(): Promise { if (stagingDir === null) return - try { - await fs.rmdir(stagingDir) - } catch { - // non-empty (a concurrent write is still staging) or already gone + for (let attempt = 0; attempt < 2; attempt++) { + try { + await fs.rmdir(stagingDir) + return + } catch (error: unknown) { + const code = _errorCode(error) + // ENOENT: another writer already removed it. ENOTEMPTY: a concurrent write is still + // staging here, which is the concurrency guard this directory exists for. Both are + // benign - no retry, no warning. + if (code === "ENOENT" || code === "ENOTEMPTY") return + // Anything else (EBUSY, EPERM, EACCES, ...) is transient here: retry once, then + // surface the retained directory. A leftover staging directory is acceptable, an + // invisible one is not. + if (attempt === 1) { + console.warn(`safeWriteText: staging directory release failed: ${stagingDir} (${code ?? String(error)})`) + } + } } } From e3203e8e8b30bbe928af3d98e236d7c2c363dfaf Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:03:35 +0800 Subject: [PATCH 32/60] fix(file-safety): do not strand a staging directory when the post-create probe errors _stagingDir re-checks the directory after mkdirSync to catch a symlink swap. If that lstatSync itself fails (EIO, or ENOENT if the directory is removed in between) the error propagated on its own, leaving a staging directory this call had just created: the caller never receives the path, so nothing else can remove it. Wrap the re-check, call _abandonIfCreated() - which only removes a directory this call created - and rethrow the original error unchanged. Regression test scripts the staging directory's own probes by path, not by call position: this flow also lstats ancestors for the symlink refusal, so a mockRejectedValueOnce chain is consumed by the wrong site. Negative control as measured: removing the _abandonIfCreated() call turns exactly one test red. Local: safeWriteText.spec 75 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 49 ++++++++++--------- src/services/file-safety/safeWriteText.ts | 11 ++++- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index e579f6c7f8..54a3531ed1 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -370,7 +370,6 @@ describe("safeWriteText", () => { vi.mocked(fsSync.statSync).mockImplementation((() => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) }) as never) await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refusing to publish/) - expect(fs.rename).not.toHaveBeenCalled() }) it("fails closed when the dump is a regular file but empty", async () => { @@ -383,7 +382,6 @@ describe("safeWriteText", () => { vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 0 })) as never) await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refus/) - expect(fs.rename).not.toHaveBeenCalled() }) it.each([["EACCES", "EACCES"], ["a code-less probe error", undefined]])("propagates a %s target-existence probe instead of treating the target as absent", async (_label, code) => { @@ -404,7 +402,6 @@ describe("safeWriteText", () => { }) as never) await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow("probe failed") - expect(fs.rename).not.toHaveBeenCalled() expect(execFile).not.toHaveBeenCalled() // Nothing downstream of the probe may run: a swallowed probe error would keep going and // copy the target to a backup, which is what makes this assertion load-bearing. @@ -457,7 +454,6 @@ describe("safeWriteText", () => { // Fail closed: the target is never published, because publishing would replace its security // descriptor with inherited permissions and nothing could put the original back. - expect(fs.rename).not.toHaveBeenCalled() // Only the save ran: a failed capture must not be followed by a restore attempt. expect(execFile).toHaveBeenCalledTimes(1) // The staging file and any partial dump are still cleaned up by the rollback. @@ -763,8 +759,6 @@ describe("safeWriteText", () => { // Falling back to 0o644 here could publish a 0o600 target with a wider mode. await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) - expect(fsSync.openSync).not.toHaveBeenCalled() - expect(fs.rename).not.toHaveBeenCalled() }) it("refuses to publish a caller-supplied temp when the target mode is unknown", async () => { @@ -783,7 +777,6 @@ describe("safeWriteText", () => { ).rejects.toBe(eacces) expect(fsSync.fchmodSync).not.toHaveBeenCalled() - expect(fs.rename).not.toHaveBeenCalled() }) it("keeps the temp's default mode when the target does not exist yet (ENOENT)", async () => { @@ -1034,7 +1027,6 @@ describe("safeWriteText", () => { ) expect(fsSync.mkdirSync).not.toHaveBeenCalled() - expect(fs.rename).not.toHaveBeenCalled() }) it.skipIf(process.platform === "win32")("refuses to stage in a .file-safety-staging directory owned by another uid", async () => { @@ -1051,8 +1043,6 @@ describe("safeWriteText", () => { ) // Nothing is staged and nothing is published from the foreign directory. - expect(fsSync.openSync).not.toHaveBeenCalled() - expect(fs.rename).not.toHaveBeenCalled() }) it("removes the staging directory once the write is over", async () => { @@ -1096,7 +1086,6 @@ describe("safeWriteText", () => { // One staging create plus one recovery attempt, then the error surfaces. expect(fsSync.mkdirSync).toHaveBeenCalledTimes(2) expect(fsSync.openSync).toHaveBeenCalledTimes(2) - expect(fs.rename).not.toHaveBeenCalled() }) it("retries a transient Windows sharing failure during the commit rename", async () => { @@ -1180,7 +1169,6 @@ describe("safeWriteText", () => { const name = await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" })) expect(name).toBe("UnsafeStagingDirectoryError") - expect(fs.rename).not.toHaveBeenCalled() }) it("removes a staging directory it created when the post-create re-check rejects it", async () => { @@ -1230,12 +1218,10 @@ describe("safeWriteText", () => { 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 () => { @@ -1260,7 +1246,6 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( "access failed", ) - expect(fs.rename).not.toHaveBeenCalled() }) }) @@ -1279,7 +1264,6 @@ describe("safeWriteText", () => { // The descriptor opened for the staging file must still be closed. expect(fsSync.closeSync).toHaveBeenCalledWith(1) // Nothing may be renamed into the target position after a partial write. - expect(fs.rename).not.toHaveBeenCalled() // The truncated staging file is removed on failure. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) }) @@ -1296,7 +1280,6 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "hello world", { platform: "linux" })).rejects.toBe(fsyncError) expect(fsSync.closeSync).toHaveBeenCalledWith(1) - expect(fs.rename).not.toHaveBeenCalled() expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) }) @@ -1331,7 +1314,6 @@ describe("safeWriteText", () => { // The dump path stays tracked so the rollback removes the file the failed save created. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl.tmp")) - expect(fs.rename).not.toHaveBeenCalled() }) }) }) @@ -1421,8 +1403,6 @@ describe("parent directory creation (safeWriteText.ts:288-290)", () => { await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(err) - expect(fsSync.openSync).not.toHaveBeenCalled() - expect(fs.rename).not.toHaveBeenCalled() }) it("propagates an access failure on the parent directory before any staging, open or publish", async () => { @@ -1432,8 +1412,6 @@ describe("parent directory creation (safeWriteText.ts:288-290)", () => { await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(err) - expect(fsSync.openSync).not.toHaveBeenCalled() - expect(fs.rename).not.toHaveBeenCalled() }) it("creates and verifies the parent directory on the successful path", async () => { @@ -1491,4 +1469,31 @@ describe("staging directory release", () => { expect(warn).not.toHaveBeenCalled() warn.mockRestore() }) +}) + +describe("post-create staging probe failure", () => { + it("removes a staging directory it created when the post-create lstat fails", async () => { + const ioError = Object.assign(new Error("EIO"), { code: "EIO" }) + vi.mocked(fs.realpath).mockResolvedValue("/tmp/test-dir/target.txt") + vi.mocked(fsSync.openSync).mockReturnValue(1) + // Path-aware, not positional: the ancestor walk also calls lstatSync, so a once-chain + // would be consumed by the wrong site. Only the staging directory's own probes are + // scripted here - absent, then an I/O error on the post-create re-check. + let stagingProbes = 0 + vi.mocked(fsSync.lstatSync).mockImplementation(((target: string) => { + if (String(target).includes(".file-safety-staging")) { + stagingProbes++ + if (stagingProbes === 1) { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + } + throw ioError + } + return _dirStats() + }) as never) + + await expect(safeWriteText("/tmp/test-dir/target.txt", "data", { platform: "linux" })).rejects.toBe(ioError) + + expect(stagingProbes).toBe(2) + expect(fsSync.rmdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) }) \ No newline at end of file diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 7df22009f3..38945c2dad 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -132,7 +132,16 @@ function _stagingDir(dir: string): string { fsSync.mkdirSync(sd, { recursive: true, mode: 0o700 }) // Re-check after the create: the path can be swapped for a symlink between the // check above and mkdirSync. - const created = fsSync.lstatSync(sd) + let created: fsSync.Stats + try { + created = fsSync.lstatSync(sd) + } catch (error: unknown) { + // The directory exists at this point and this call may have just created it. Letting the + // probe error propagate would strand it: the caller never receives the path, so nothing + // else can remove it. Abandon first, then rethrow the original error. + _abandonIfCreated() + throw error + } if (!created.isDirectory()) { _abandonIfCreated() throw new UnsafeStagingDirectoryError(sd, "it was replaced by a non-directory") From b3cf2d9168ab659e507c3ac29bdf0b4a9427568b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:05:29 +0800 Subject: [PATCH 33/60] test(file-safety): exercise safeWriteText against a real filesystem Every other suite in this directory mocks fs/promises and fs, so nothing proves the real open/write/fsync/rename sequence produces bytes on disk. This suite uses a real temporary directory with no mocks and asserts the outcome for a new target and an existing target, plus the absence of staging, temp or backup residue. Negative control as measured: commenting out the publish rename (safeWriteText.ts:509) turns BOTH tests red. Honest limit: the residue assertion was not proven load-bearing by neutering the unlinks at :573 and :579 - those sites produce no residue in these scenarios, so those runs stayed green. The residue check is a guardrail, not a claim backed by its own control. Local: integration spec 2 passed; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../safeWriteText.integration.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/services/file-safety/__tests__/safeWriteText.integration.spec.ts diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts new file mode 100644 index 0000000000..861696de6b --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -0,0 +1,46 @@ +import fsSync from "node:fs" +import os from "node:os" +import path from "node:path" + +import { safeWriteText } from "../safeWriteText" + +/** + * Every other suite in this directory mocks fs/promises and fs, so nothing there proves the real + * open/write/fsync/rename sequence produces a file on disk. This suite uses a real temporary + * directory and no mocks at all: it asserts the outcome (bytes on disk) and the absence of residue + * (staging directory, temp file, backup). + */ +describe("safeWriteText against a real filesystem", () => { + let dir: string + + beforeEach(() => { + dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "safe-write-it-")) + }) + + afterEach(() => { + fsSync.rmSync(dir, { recursive: true, force: true }) + }) + + function _leftovers(): string[] { + return fsSync.readdirSync(dir).filter((name) => name.includes(".file-safety-staging") || name.includes(".new_") || name.includes(".bak")) + } + + it("creates a new file with the requested bytes and leaves no residue", async () => { + const target = path.join(dir, "brand-new.txt") + + await safeWriteText(target, "first content") + + expect(fsSync.readFileSync(target, "utf8")).toBe("first content") + expect(_leftovers()).toEqual([]) + }) + + it("replaces an existing file and leaves no residue", async () => { + const target = path.join(dir, "existing.txt") + fsSync.writeFileSync(target, "old") + + await safeWriteText(target, "replacement") + + expect(fsSync.readFileSync(target, "utf8")).toBe("replacement") + expect(_leftovers()).toEqual([]) + }) +}) \ No newline at end of file From 02fe9ac0fdc35cc9ba3cd895d50ca11157a45a6a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:41:23 +0800 Subject: [PATCH 34/60] test(file-safety): cover the win32 DACL restore retry that succeeds on the second attempt Regression: the suite covered both restore attempts failing, but not the retry actually working - the branch where the first /restore fails transiently and the retry restores the descriptor, after which the backup is no longer the recovery artifact and is removed. Test drives execFile so /save succeeds, the first /restore fails and the retry succeeds, then asserts one /save, two /restore calls, the commit rename ran, and the .bak was unlinked. Negative control as measured: forcing the retry guard at safeWriteText.ts:548 to false turns exactly one test red - this one. An earlier attempt to mutate the runner call at :216 produced a collection error ('no tests'), which is not a control; the guard mutation is the valid one. Local: safeWriteText.spec 76 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 54a3531ed1..d1d5411cf5 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -681,6 +681,32 @@ describe("safeWriteText", () => { expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining(".bak")) }) + it("win32 DACL: a failed first restore is retried and the backup is then removed", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // /save succeeds (call 1), the first /restore fails (call 2), the retry succeeds (call 3). + let callCount = 0 + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + callCount++ + if (typeof cb === "function") { + cb(callCount === 2 ? new Error("icacls transient error") : null, "", "") + } + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32", backup: true }) + + const saves = vi.mocked(execFile).mock.calls.filter(function (call) { return String(call[1]).includes("/save") }) + const restores = vi.mocked(execFile).mock.calls.filter(function (call) { return String(call[1]).includes("/restore") }) + expect(saves.length).toBe(1) + expect(restores.length).toBe(2) + expect(fs.rename).toHaveBeenCalled() + // The descriptor is back, so the backup is no longer the recovery artifact and is removed. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".bak")) + }) + it("win32 DACL: when target does not exist, no save/restore/dump", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) From 9a449c1bbdec62a76be4022172e9644bcfb5834a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 10:50:43 +0800 Subject: [PATCH 35/60] test(file-safety): cover the non-regular DACL dump branch _dumpIsUsable requires the saved icacls dump to be a regular, non-empty file. The suite covered a missing dump and an empty one, but not the case where the path exists with bytes in it and is not a regular file (a directory, FIFO or device planted by another process) - restoring a descriptor from such a path is not a restore of ours, so the write must fail closed. Test makes statSync report isFile() false with size 4096 and asserts the failure and that no publish rename happens. Negative control as measured: relaxing _dumpIsUsable to 'st.size > 0' (dropping the isFile() requirement at safeWriteText.ts:203) turns exactly one test red - this one. Local: safeWriteText.spec 77 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn. --- .../__tests__/safeWriteText.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index d1d5411cf5..167f23e163 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -681,6 +681,24 @@ describe("safeWriteText", () => { expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining(".bak")) }) + it("win32 DACL: a non-regular dump file fails closed even when it is non-empty", 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(null, "", "") } + return fakeChild + }) + // The dump exists with bytes in it, but it is not a regular file (a directory, a FIFO or a + // device left behind by another process). icacls /restore against it is not a restore of our + // descriptor, so the write has to fail closed instead of publishing. + vi.mocked(fsSync.statSync).mockReturnValue({ isFile: () => false, size: 4096 } as never) + + await expect(safeWriteText(targetPath, "data", { platform: "win32", backup: true })).rejects.toThrow(/DaclCaptureError|refus/i) + + expect(fs.rename).not.toHaveBeenCalled() + }) + it("win32 DACL: a failed first restore is retried and the backup is then removed", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) From 90106179864cf3008ae3bfba628e44893b7c5f06 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 11:23:46 +0800 Subject: [PATCH 36/60] fix(file-safety): retry the staging temp unlink instead of swallowing its failure The rollback path cleaned the staging temp with an unlink whose error was discarded, so a transient EBUSY/EPERM left the temp inside the staging directory and nothing ever reported the retained path. Use the bounded retry already used for the DACL dump and backup cleanup: two attempts, ENOENT counts as already released, and a persistent failure warns once naming the retained path. Tests: a transient failure is retried and stays silent; a persistent failure warns exactly once. Both drive the rollback path by making the publish rename reject with EBUSY and count only unlinks under .file-safety-staging - an earlier filter on the temp prefix also matched the DACL dump and produced a count that could not be interpreted. Negative control as measured: restoring the single swallowing unlink turns exactly two tests red - these two. Local: safeWriteText.spec 79 passed / 1 skipped; src-level tsc --noEmit 0; eslint 0 err / 0 warn on both files. --- .../__tests__/safeWriteText.spec.ts | 40 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 23 ++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 167f23e163..28dde804a4 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -681,6 +681,46 @@ describe("safeWriteText", () => { expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining(".bak")) }) + it("staging temp release: a transient unlink failure on the rollback path is retried", 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("EBUSY rename"), { code: "EBUSY" })) + let stagingUnlinks = 0 + vi.mocked(fs.unlink).mockImplementation((async (path: string) => { + if (String(path).includes(".file-safety-staging")) { + stagingUnlinks++ + if (stagingUnlinks === 1) { throw Object.assign(new Error("EBUSY unlink"), { code: "EBUSY" }) } + } + }) as never) + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EBUSY rename") + expect(stagingUnlinks).toBe(2) + expect(warn.mock.calls.filter(function (call) { return String(call[0]).includes("staging temp release failed") }).length).toBe(0) + warn.mockRestore() + }) + + it("staging temp release: a persistent failure names the retained path exactly once", 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("EBUSY rename"), { code: "EBUSY" })) + let stagingUnlinks = 0 + vi.mocked(fs.unlink).mockImplementation((async (path: string) => { + if (String(path).includes(".file-safety-staging")) { + stagingUnlinks++ + throw Object.assign(new Error("EBUSY unlink"), { code: "EBUSY" }) + } + }) as never) + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EBUSY rename") + expect(stagingUnlinks).toBe(2) + expect(warn.mock.calls.filter(function (call) { return String(call[0]).includes("staging temp release failed") }).length).toBe(1) + warn.mockRestore() + }) + it("win32 DACL: a non-regular dump file fails closed even when it is non-empty", 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 38945c2dad..e0f9004c0b 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -622,11 +622,24 @@ export async function safeWriteText(filePath: string, content: string, options?: } } - // Always clean up the staging temp file on failure. - try { - await fs.unlink(tempPath).catch(() => {}) - } catch { - // cleanup failure is non-fatal + // Always clean up the staging temp file on failure. A single swallowed unlink is not enough: + // a transient EBUSY/EPERM would silently leave the temp inside the staging directory, and nothing + // would ever report the retained path. Bounded retry, same shape as the DACL and backup cleanup. + for (let attempt = 0; attempt < 2; attempt++) { + try { + await fs.unlink(tempPath) + break + } catch (cleanupError: unknown) { + const code = _errorCode(cleanupError) + if (code === "ENOENT") { + break + } + if (attempt === 1) { + console.warn( + `safeWriteText: staging temp release failed: ${tempPath} (${code ?? String(cleanupError)})` + ) + } + } } if (daclDumpPath !== null) { From 784a586b533c98fc0488b51bde19ef637b554623 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 17:59:09 +0800 Subject: [PATCH 37/60] style: bring the branch's own files to the repo's prettier output The merge made this branch's files the last unformatted ones in the tree: prettier --check on the merged tree named exactly these eight, and nothing else, because main already carries the ignore file and the format gate that the compile job runs. Formatting only. Every change is prettier re-wrapping a call or adding the trailing comma that comes with the wrap; no identifier, string, or control flow moved. Verified by formatting the same inputs through prettier twice and by the checks below rather than by eye. After this commit prettier --check reports the tree clean, eslint still runs with --max-warnings=0 clean, and src/eslint-suppressions.json gained nothing. --- src/integrations/editor/DiffViewProvider.ts | 5 +- .../editor/__tests__/DiffViewProvider.spec.ts | 14 +- .../safeWriteText.integration.spec.ts | 6 +- .../__tests__/safeWriteText.spec.ts | 487 +++++++++++------- src/services/file-safety/safeWriteText.ts | 48 +- 5 files changed, 335 insertions(+), 225 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 481f87a788..be0d1aafb5 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1166,7 +1166,10 @@ export class DiffViewProvider { try { await fs.access(absolutePath, fsConstants.W_OK) } catch (error: unknown) { - const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code : undefined + const code = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined if (code !== "ENOENT") throw error } await safeWriteText(absolutePath, content) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 2c189b931b..37bd61041d 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -787,9 +787,9 @@ describe("DiffViewProvider", () => { const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) vi.mocked(fs.access).mockRejectedValueOnce(eacces) - await expect( - diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200), - ).rejects.toThrow("EACCES") + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200)).rejects.toThrow( + "EACCES", + ) // The old fs.writeFile path failed the same way; safeWriteText must not run. expect(safeWriteText).not.toHaveBeenCalled() @@ -824,10 +824,10 @@ describe("DiffViewProvider", () => { expect(accessIndex).toBeGreaterThanOrEqual(0) expect(calls[accessIndex][0]).toBe(`${mockCwd}/test.ts`) - // An existence-only check would not catch a read-only target: the guard has to be handed - // the write bit, otherwise the read-only contract this unit preserves is silently gone. - const { constants } = await import("fs") - expect(calls[accessIndex][1]).toBe(constants.W_OK) + // An existence-only check would not catch a read-only target: the guard has to be handed + // the write bit, otherwise the read-only contract this unit preserves is silently gone. + const { constants } = await import("fs") + expect(calls[accessIndex][1]).toBe(constants.W_OK) // The check is only meaningful before the publish; after it, an EACCES target would // already have been renamed over. diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts index 861696de6b..58db29d832 100644 --- a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -22,7 +22,9 @@ describe("safeWriteText against a real filesystem", () => { }) function _leftovers(): string[] { - return fsSync.readdirSync(dir).filter((name) => name.includes(".file-safety-staging") || name.includes(".new_") || name.includes(".bak")) + return fsSync + .readdirSync(dir) + .filter((name) => name.includes(".file-safety-staging") || name.includes(".new_") || name.includes(".bak")) } it("creates a new file with the requested bytes and leaves no residue", async () => { @@ -43,4 +45,4 @@ describe("safeWriteText against a real filesystem", () => { expect(fsSync.readFileSync(target, "utf8")).toBe("replacement") expect(_leftovers()).toEqual([]) }) -}) \ No newline at end of file +}) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 28dde804a4..442c27dcb0 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -250,11 +250,11 @@ describe("safeWriteText", () => { // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── describe("backup:true", () => { - // These tests exercise the win32 publish path, whose DACL capture is judged by its - // artifact, so the mock world has to produce a usable dump. - beforeEach(() => { - vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 256 })) as never) - }) + // These tests exercise the win32 publish path, whose DACL capture is judged by its + // artifact, so the mock world has to produce a usable dump. + beforeEach(() => { + vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 256 })) as never) + }) it("copies target -> backup before commit, deletes backup on success", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -318,11 +318,11 @@ describe("safeWriteText", () => { // ── Test 5: win32 DACL path ────────────────────────────────────────────── describe("win32 DACL", () => { - // The DACL capture is judged by its artifact, so the mock world has to produce one: - // a regular, non-empty dump. Tests that want an unusable artifact override this. - beforeEach(() => { - vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 256 })) as never) - }) + // The DACL capture is judged by its artifact, so the mock world has to produce one: + // a regular, non-empty dump. Tests that want an unusable artifact override this. + beforeEach(() => { + vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 256 })) as never) + }) it.skipIf(process.platform !== "win32")( "copies target DACL onto staging file via icacls before rename on Windows", async () => { @@ -366,10 +366,22 @@ describe("safeWriteText", () => { vi.mocked(fsSync.openSync).mockReturnValue(1) // icacls exits 0 without producing the dump (measured when the target's owner differs // from the caller). The exit code alone is not evidence that a descriptor was captured. - vi.mocked(execFile).mockImplementation(((_c: string, _a: string[], _o: unknown, cb?: (e?: Error | null) => void) => { cb?.(null); return undefined }) as never) - vi.mocked(fsSync.statSync).mockImplementation((() => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) }) as never) + vi.mocked(execFile).mockImplementation((( + _c: string, + _a: string[], + _o: unknown, + cb?: (e?: Error | null) => void, + ) => { + cb?.(null) + return undefined + }) as never) + vi.mocked(fsSync.statSync).mockImplementation((() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) as never) - await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refusing to publish/) + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow( + /refusing to publish/, + ) }) it("fails closed when the dump is a regular file but empty", async () => { @@ -378,13 +390,24 @@ describe("safeWriteText", () => { vi.mocked(fsSync.openSync).mockReturnValue(1) // A zero-byte dump is not a captured security descriptor: icacls can create the file and // write nothing into it. Accepting it would publish with a restore that carries no data. - vi.mocked(execFile).mockImplementation(((_c: string, _a: string[], _o: unknown, cb?: (e?: Error | null) => void) => { cb?.(null); return undefined }) as never) + vi.mocked(execFile).mockImplementation((( + _c: string, + _a: string[], + _o: unknown, + cb?: (e?: Error | null) => void, + ) => { + cb?.(null) + return undefined + }) as never) vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 0 })) as never) await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refus/) }) - it.each([["EACCES", "EACCES"], ["a code-less probe error", undefined]])("propagates a %s target-existence probe instead of treating the target as absent", async (_label, code) => { + it.each([ + ["EACCES", "EACCES"], + ["a code-less probe error", undefined], + ])("propagates a %s target-existence probe instead of treating the target as absent", async (_label, code) => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -397,7 +420,9 @@ describe("safeWriteText", () => { // the flow, so a first-call mock fails the WRONG probe and the assertion is satisfied by // accident. Only the probe of the target itself may fail here. vi.mocked(fs.access).mockImplementation((async (p: string) => { - if (p === targetPath) { throw Object.assign(new Error("probe failed"), code ? { code } : {}) } + if (p === targetPath) { + throw Object.assign(new Error("probe failed"), code ? { code } : {}) + } return undefined }) as never) @@ -412,35 +437,59 @@ describe("safeWriteText", () => { 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" }) }) as never) + vi.mocked(fsSync.statSync).mockImplementation((() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) as never) const unlinkMock = vi.mocked(fs.unlink) - await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refusing to publish/) + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow( + /refusing to publish/, + ) // The abort leaves the try/finally that owns cleanup, so the artifacts are released on the // abort path itself: neither the dump nor the staged file may be orphaned. - const unlinked = unlinkMock.mock.calls.map(function (call) { return String(call[0]) }) - expect(unlinked.some(function (p) { return p.includes(".acl.tmp") })).toBe(true) - expect(unlinked.some(function (p) { return p.includes("safeWriteText_") })).toBe(true) + const unlinked = unlinkMock.mock.calls.map(function (call) { + return String(call[0]) + }) + expect( + unlinked.some(function (p) { + return p.includes(".acl.tmp") + }), + ).toBe(true) + expect( + unlinked.some(function (p) { + return p.includes("safeWriteText_") + }), + ).toBe(true) }) it("reports the retained artifact when the abort cleanup cannot remove it", 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" }) }) as never) + vi.mocked(fsSync.statSync).mockImplementation((() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) as never) vi.mocked(fs.unlink).mockRejectedValue(new Error("EBUSY")) const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refusing to publish/) + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow( + /refusing to publish/, + ) - const warned = warnSpy.mock.calls.map(function (call) { return String(call[0]) }).join("\n") + const warned = warnSpy.mock.calls + .map(function (call) { + return String(call[0]) + }) + .join("\n") expect(warned).toContain("could not remove") }) it("win32 DACL capture failure aborts before publishing", async () => { // The capture is judged by the artifact: make the dump absent so the capture fails. - vi.mocked(fsSync.statSync).mockImplementation((() => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) }) as never) + vi.mocked(fsSync.statSync).mockImplementation((() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) as never) const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -450,7 +499,9 @@ describe("safeWriteText", () => { return fakeChild }) - await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow(/refusing to publish/) + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow( + /refusing to publish/, + ) // Fail closed: the target is never published, because publishing would replace its security // descriptor with inherited permissions and nothing could put the original back. @@ -564,7 +615,9 @@ describe("safeWriteText", () => { unlinkMock.mockImplementation(async (p) => { if (typeof p === "string" && p.includes(".acl.tmp")) { dumpUnlinks++ - if (dumpUnlinks === 1) { throw new Error("EBUSY") } + if (dumpUnlinks === 1) { + throw new Error("EBUSY") + } } return undefined }) @@ -583,7 +636,9 @@ describe("safeWriteText", () => { vi.mocked(fsSync.openSync).mockReturnValue(1) const unlinkMock = vi.mocked(fs.unlink) unlinkMock.mockImplementation(async (p) => { - if (typeof p === "string" && p.includes(".acl.tmp")) { throw new Error("EBUSY") } + if (typeof p === "string" && p.includes(".acl.tmp")) { + throw new Error("EBUSY") + } return undefined }) const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) @@ -596,175 +651,203 @@ describe("safeWriteText", () => { return typeof call[0] === "string" && call[0].includes(".acl.tmp") }) expect(dumpCalls).toHaveLength(2) - const warned = warnSpy.mock.calls.map(function (call) { return String(call[0]) }).join("\n") + const warned = warnSpy.mock.calls + .map(function (call) { + return String(call[0]) + }) + .join("\n") expect(warned).toContain(".acl.tmp") }) - it("keeps the publish and warns when both backup-removal attempts fail", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - // Only the backup unlink fails, twice; every other unlink works. - const unlinkMock = vi.mocked(fs.unlink) - unlinkMock.mockImplementation(async (p) => { - if (typeof p === "string" && p.includes(".bak")) { throw new Error("EBUSY") } - return undefined - }) - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + it("keeps the publish and warns when both backup-removal attempts fail", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // Only the backup unlink fails, twice; every other unlink works. + const unlinkMock = vi.mocked(fs.unlink) + unlinkMock.mockImplementation(async (p) => { + if (typeof p === "string" && p.includes(".bak")) { + throw new Error("EBUSY") + } + return undefined + }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - await safeWriteText(targetPath, "new data", { backup: true }) + await safeWriteText(targetPath, "new data", { backup: true }) - // The write itself is unaffected - the backup is cleanup, not part of the commit. - expect(fs.rename).toHaveBeenCalled() - const bakCalls = unlinkMock.mock.calls.filter(function (call) { - return typeof call[0] === "string" && call[0].includes(".bak") + // The write itself is unaffected - the backup is cleanup, not part of the commit. + expect(fs.rename).toHaveBeenCalled() + const bakCalls = unlinkMock.mock.calls.filter(function (call) { + return typeof call[0] === "string" && call[0].includes(".bak") + }) + expect(bakCalls).toHaveLength(2) + const warned = warnSpy.mock.calls + .map(function (call) { + return String(call[0]) + }) + .join("\n") + expect(warned).toContain("could not be removed") + expect(warned).toContain(".bak") }) - expect(bakCalls).toHaveLength(2) - const warned = warnSpy.mock.calls.map(function (call) { return String(call[0]) }).join("\n") - expect(warned).toContain("could not be removed") - expect(warned).toContain(".bak") - }) - it("removes a partially copied backup when the copy itself fails", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - // copyFile can create the destination and then fail part way through. - vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + it("removes a partially copied backup when the copy itself fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // copyFile can create the destination and then fail part way through. + vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) - await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("EACCES") + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("EACCES") - // The half-written backup must not be left next to the target, and the original error - // is still the one that surfaces. - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".bak")) - }) + // The half-written backup must not be left next to the target, and the original error + // is still the one that surfaces. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".bak")) + }) - it("closes the staging descriptor when applying the target mode fails", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(7) - vi.mocked(fsSync.fchmodSync).mockImplementation(() => { - throw Object.assign(new Error("EPERM"), { code: "EPERM" }) + it("closes the staging descriptor when applying the target mode fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(7) + vi.mocked(fsSync.fchmodSync).mockImplementation(() => { + throw Object.assign(new Error("EPERM"), { code: "EPERM" }) + }) + + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EPERM") + + // The descriptor is opened before the mode is applied, so the close has to live in the + // finally that also covers the mode call - otherwise a mode failure leaks the fd. + expect(fsSync.closeSync).toHaveBeenCalledWith(7) + expect(fsSync.writeSync).not.toHaveBeenCalled() }) - await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EPERM") + it("win32 DACL: when both restore attempts fail, the backup is retained", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) - // The descriptor is opened before the mode is applied, so the close has to live in the - // finally that also covers the mode call - otherwise a mode failure leaks the fd. - expect(fsSync.closeSync).toHaveBeenCalledWith(7) - expect(fsSync.writeSync).not.toHaveBeenCalled() - }) + // /save succeeds (call 1); both /restore attempts fail (calls 2 and 3). + 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 + }) - it("win32 DACL: when both restore attempts fail, the backup is retained", 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: "win32", backup: true }) - // /save succeeds (call 1); both /restore attempts fail (calls 2 and 3). - 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 + // The content is still committed - a failed ACL restore is not a write failure. + expect(fs.rename).toHaveBeenCalled() + // The dump is still cleaned up in the finally block. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + // But the backup survives: it is the only artifact that still carries the target's + // original security descriptor, so deleting it would destroy the recovery path. + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining(".bak")) }) - await safeWriteText(targetPath, "data", { platform: "win32", backup: true }) + it("staging temp release: a transient unlink failure on the rollback path is retried", 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("EBUSY rename"), { code: "EBUSY" })) + let stagingUnlinks = 0 + vi.mocked(fs.unlink).mockImplementation((async (path: string) => { + if (String(path).includes(".file-safety-staging")) { + stagingUnlinks++ + if (stagingUnlinks === 1) { + throw Object.assign(new Error("EBUSY unlink"), { code: "EBUSY" }) + } + } + }) as never) + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) - // The content is still committed - a failed ACL restore is not a write failure. - expect(fs.rename).toHaveBeenCalled() - // The dump is still cleaned up in the finally block. - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) - // But the backup survives: it is the only artifact that still carries the target's - // original security descriptor, so deleting it would destroy the recovery path. - expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining(".bak")) - }) + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EBUSY rename") + expect(stagingUnlinks).toBe(2) + expect( + warn.mock.calls.filter(function (call) { + return String(call[0]).includes("staging temp release failed") + }).length, + ).toBe(0) + warn.mockRestore() + }) - it("staging temp release: a transient unlink failure on the rollback path is retried", 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("EBUSY rename"), { code: "EBUSY" })) - let stagingUnlinks = 0 - vi.mocked(fs.unlink).mockImplementation((async (path: string) => { - if (String(path).includes(".file-safety-staging")) { - stagingUnlinks++ - if (stagingUnlinks === 1) { throw Object.assign(new Error("EBUSY unlink"), { code: "EBUSY" }) } - } - }) as never) - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + it("staging temp release: a persistent failure names the retained path exactly once", 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("EBUSY rename"), { code: "EBUSY" })) + let stagingUnlinks = 0 + vi.mocked(fs.unlink).mockImplementation((async (path: string) => { + if (String(path).includes(".file-safety-staging")) { + stagingUnlinks++ + throw Object.assign(new Error("EBUSY unlink"), { code: "EBUSY" }) + } + }) as never) + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) - await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EBUSY rename") - expect(stagingUnlinks).toBe(2) - expect(warn.mock.calls.filter(function (call) { return String(call[0]).includes("staging temp release failed") }).length).toBe(0) - warn.mockRestore() - }) + await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EBUSY rename") + expect(stagingUnlinks).toBe(2) + expect( + warn.mock.calls.filter(function (call) { + return String(call[0]).includes("staging temp release failed") + }).length, + ).toBe(1) + warn.mockRestore() + }) - it("staging temp release: a persistent failure names the retained path exactly once", 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("EBUSY rename"), { code: "EBUSY" })) - let stagingUnlinks = 0 - vi.mocked(fs.unlink).mockImplementation((async (path: string) => { - if (String(path).includes(".file-safety-staging")) { - stagingUnlinks++ - throw Object.assign(new Error("EBUSY unlink"), { code: "EBUSY" }) - } - }) as never) - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + it("win32 DACL: a non-regular dump file fails closed even when it is non-empty", 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(null, "", "") + } + return fakeChild + }) + // The dump exists with bytes in it, but it is not a regular file (a directory, a FIFO or a + // device left behind by another process). icacls /restore against it is not a restore of our + // descriptor, so the write has to fail closed instead of publishing. + vi.mocked(fsSync.statSync).mockReturnValue({ isFile: () => false, size: 4096 } as never) - await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EBUSY rename") - expect(stagingUnlinks).toBe(2) - expect(warn.mock.calls.filter(function (call) { return String(call[0]).includes("staging temp release failed") }).length).toBe(1) - warn.mockRestore() - }) + await expect(safeWriteText(targetPath, "data", { platform: "win32", backup: true })).rejects.toThrow( + /DaclCaptureError|refus/i, + ) - it("win32 DACL: a non-regular dump file fails closed even when it is non-empty", 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(null, "", "") } - return fakeChild + expect(fs.rename).not.toHaveBeenCalled() }) - // The dump exists with bytes in it, but it is not a regular file (a directory, a FIFO or a - // device left behind by another process). icacls /restore against it is not a restore of our - // descriptor, so the write has to fail closed instead of publishing. - vi.mocked(fsSync.statSync).mockReturnValue({ isFile: () => false, size: 4096 } as never) - await expect(safeWriteText(targetPath, "data", { platform: "win32", backup: true })).rejects.toThrow(/DaclCaptureError|refus/i) + it("win32 DACL: a failed first restore is retried and the backup is then removed", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) - expect(fs.rename).not.toHaveBeenCalled() - }) + // /save succeeds (call 1), the first /restore fails (call 2), the retry succeeds (call 3). + let callCount = 0 + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + callCount++ + if (typeof cb === "function") { + cb(callCount === 2 ? new Error("icacls transient error") : null, "", "") + } + return fakeChild + }) - it("win32 DACL: a failed first restore is retried and the backup is then removed", 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: "win32", backup: true }) - // /save succeeds (call 1), the first /restore fails (call 2), the retry succeeds (call 3). - let callCount = 0 - vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { - callCount++ - if (typeof cb === "function") { - cb(callCount === 2 ? new Error("icacls transient error") : null, "", "") - } - return fakeChild + const saves = vi.mocked(execFile).mock.calls.filter(function (call) { + return String(call[1]).includes("/save") + }) + const restores = vi.mocked(execFile).mock.calls.filter(function (call) { + return String(call[1]).includes("/restore") + }) + expect(saves.length).toBe(1) + expect(restores.length).toBe(2) + expect(fs.rename).toHaveBeenCalled() + // The descriptor is back, so the backup is no longer the recovery artifact and is removed. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".bak")) }) - await safeWriteText(targetPath, "data", { platform: "win32", backup: true }) - - const saves = vi.mocked(execFile).mock.calls.filter(function (call) { return String(call[1]).includes("/save") }) - const restores = vi.mocked(execFile).mock.calls.filter(function (call) { return String(call[1]).includes("/restore") }) - expect(saves.length).toBe(1) - expect(restores.length).toBe(2) - expect(fs.rename).toHaveBeenCalled() - // The descriptor is back, so the backup is no longer the recovery artifact and is removed. - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".bak")) - }) - 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) @@ -842,7 +925,6 @@ describe("safeWriteText", () => { // Falling back to 0o644 here could publish a 0o600 target with a wider mode. await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) - }) it("refuses to publish a caller-supplied temp when the target mode is unknown", async () => { @@ -958,12 +1040,10 @@ describe("safeWriteText", () => { expect(renames.at(-1)?.[1]).toBe(referent) expect(renames.some((call) => call[1] === linkPath)).toBe(false) }) - }) // ── Test 8: review fixes (permissions, partial writes, resolution, durability) ── - describe("review fixes", () => { it("applies an existing target's mode with fchmodSync, not the umask-masked creation mode", async () => { const targetPath = "/tmp/test-dir/target.txt" @@ -1089,11 +1169,9 @@ describe("safeWriteText", () => { throw new Error("EBADF") }) - expect( - await _rejectionName( - safeWriteText(targetPath, "data", { platform: "linux", backup: true }), - ), - ).toBe("PublishNotDurableError") + expect(await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux", backup: true }))).toBe( + "PublishNotDurableError", + ) // The previous content stays recoverable while durability is unconfirmed. expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) @@ -1113,21 +1191,24 @@ describe("safeWriteText", () => { expect(fsSync.mkdirSync).not.toHaveBeenCalled() }) - it.skipIf(process.platform === "win32")("refuses to stage in a .file-safety-staging directory owned by another uid", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - // A staging directory that exists but belongs to a different uid: the writer must - // not place temp files where another user could observe or pre-create them. - const foreign = _dirStats() as fsSync.Stats & { uid?: number } - foreign.uid = (process.getuid?.() ?? 0) + 1 - vi.mocked(fsSync.lstatSync).mockReturnValue(foreign) + it.skipIf(process.platform === "win32")( + "refuses to stage in a .file-safety-staging directory owned by another uid", + async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // A staging directory that exists but belongs to a different uid: the writer must + // not place temp files where another user could observe or pre-create them. + const foreign = _dirStats() as fsSync.Stats & { uid?: number } + foreign.uid = (process.getuid?.() ?? 0) + 1 + vi.mocked(fsSync.lstatSync).mockReturnValue(foreign) - expect(await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" }))).toBe( - "UnsafeStagingDirectoryError", - ) + expect(await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" }))).toBe( + "UnsafeStagingDirectoryError", + ) - // Nothing is staged and nothing is published from the foreign directory. - }) + // Nothing is staged and nothing is published from the foreign directory. + }, + ) it("removes the staging directory once the write is over", async () => { const targetPath = "/tmp/test-dir/target.txt" @@ -1374,7 +1455,9 @@ describe("safeWriteText", () => { const publishError = new Error("EXDEV: cross-device rename not permitted") vi.mocked(fs.rename).mockRejectedValueOnce(publishError) - await expect(safeWriteText(targetPath, "hello world", { platform: "linux", backup: true })).rejects.toBe(publishError) + await expect(safeWriteText(targetPath, "hello world", { platform: "linux", backup: true })).rejects.toBe( + publishError, + ) // The backup is a copy, so the target was never renamed away and there is nothing to roll back. expect(fs.copyFile).toHaveBeenCalled() @@ -1389,12 +1472,19 @@ describe("safeWriteText", () => { vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) // execFile callback form: report a failure after the dump file was created. - vi.mocked(execFile).mockImplementationOnce(((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => { + vi.mocked(execFile).mockImplementationOnce((( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error) => void, + ) => { cb(new Error("icacl dump failed")) return fakeChild }) as unknown as typeof execFile) - await expect(safeWriteText(targetPath, "hello world", { platform: "win32" })).rejects.toThrow(/refusing to publish/) + await expect(safeWriteText(targetPath, "hello world", { platform: "win32" })).rejects.toThrow( + /refusing to publish/, + ) // The dump path stays tracked so the rollback removes the file the failed save created. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl.tmp")) @@ -1402,7 +1492,6 @@ describe("safeWriteText", () => { }) }) - describe("resolvePublishTarget symlink cycle (S1)", () => { beforeEach(() => { // This describe sits outside the file's main describe, so it clears the shared @@ -1486,7 +1575,6 @@ describe("parent directory creation (safeWriteText.ts:288-290)", () => { vi.mocked(fs.mkdir).mockRejectedValue(err) await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(err) - }) it("propagates an access failure on the parent directory before any staging, open or publish", async () => { @@ -1495,7 +1583,6 @@ describe("parent directory creation (safeWriteText.ts:288-290)", () => { vi.mocked(fs.access).mockRejectedValue(err) await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(err) - }) it("creates and verifies the parent directory on the successful path", async () => { @@ -1537,8 +1624,14 @@ describe("staging directory release", () => { await safeWriteText("/tmp/test-dir/target.txt", "data", { platform: "linux" }) - const messages = warn.mock.calls.map(function (c) { return String(c[0]) }) - expect(messages.filter(function (m) { return m.indexOf("staging directory release failed") >= 0 }).length).toBe(1) + const messages = warn.mock.calls.map(function (c) { + return String(c[0]) + }) + expect( + messages.filter(function (m) { + return m.indexOf("staging directory release failed") >= 0 + }).length, + ).toBe(1) warn.mockRestore() }) @@ -1580,4 +1673,4 @@ describe("post-create staging probe failure", () => { expect(stagingProbes).toBe(2) expect(fsSync.rmdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) }) -}) \ No newline at end of file +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e0f9004c0b..0d39b952f2 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -76,7 +76,9 @@ export class DaclCaptureError extends Error { public readonly targetPath: string, public readonly dumpPath: string, ) { - super(`safeWriteText: refusing to publish ${targetPath}: its DACL could not be captured (no icacls dump was written at ${dumpPath}). No change was made to the target.`) + super( + `safeWriteText: refusing to publish ${targetPath}: its DACL could not be captured (no icacls dump was written at ${dumpPath}). No change was made to the target.`, + ) this.name = "DaclCaptureError" } } @@ -168,10 +170,10 @@ function _fsyncFile(fd: number): void { fsSync.fsyncSync(fd) } - /** Save the DACL of *srcPath* to a dump file on Windows. - * Returns true when a usable dump exists; false otherwise. - * Never throws - a false return means no usable dump was produced, which the caller treats - * as "the security descriptor cannot be preserved" and fails closed before publishing. */ +/** Save the DACL of *srcPath* to a dump file on Windows. + * Returns true when a usable dump exists; false otherwise. + * Never throws - a false return means no usable dump was produced, which the caller treats + * as "the security descriptor cannot be preserved" and fails closed before publishing. */ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { const runner = execFileRunner ?? execFile try { @@ -209,7 +211,11 @@ function _dumpIsUsable(dumpPath: string): boolean { /** Restore a DACL dump onto *dirPath* on Windows. * Returns true when icacls reported success; false otherwise, so the caller can * decide whether a lost DACL is tolerable. */ -async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { +async function _restoreDaclWindows( + dirPath: string, + dumpPath: string, + execFileRunner?: typeof execFile, +): Promise { const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { @@ -323,7 +329,9 @@ export async function safeWriteText(filePath: string, content: string, options?: // surface the retained directory. A leftover staging directory is acceptable, an // invisible one is not. if (attempt === 1) { - console.warn(`safeWriteText: staging directory release failed: ${stagingDir} (${code ?? String(error)})`) + console.warn( + `safeWriteText: staging directory release failed: ${stagingDir} (${code ?? String(error)})`, + ) } } } @@ -331,10 +339,10 @@ export async function safeWriteText(filePath: string, content: string, options?: let backupPath: string | null = null let backupCreated = false - // Set as soon as the backup destination is chosen, before the copy runs: a copyFile that - // fails part way can still leave a partial file at that path, and it has to be cleaned up - // like a completed backup would be. - let backupAttempted = false + // Set as soon as the backup destination is chosen, before the copy runs: a copyFile that + // fails part way can still leave a partial file at that path, and it has to be cleaned up + // like a completed backup would be. + let backupAttempted = false // Set when the commit landed but its durability could not be confirmed. The // content is in place, so there is nothing to roll back, but the caller must // not be told the publish is durable. @@ -444,7 +452,9 @@ export async function safeWriteText(filePath: string, content: string, options?: options?.tempPath ? null : tempPath, // a caller-supplied temp belongs to the caller ] for (const artifact of abortArtifacts) { - if (!artifact) { continue } + if (!artifact) { + continue + } let removed = false for (let attempt = 0; attempt < 2 && !removed; attempt++) { try { @@ -455,7 +465,9 @@ export async function safeWriteText(filePath: string, content: string, options?: } } if (!removed) { - console.warn(`safeWriteText: aborted before publishing and could not remove ${artifact}; it is retained and must be cleaned up out of band.`) + console.warn( + `safeWriteText: aborted before publishing and could not remove ${artifact}; it is retained and must be cleaned up out of band.`, + ) } } throw new DaclCaptureError(targetPath, daclDumpPath) @@ -471,7 +483,8 @@ export async function safeWriteText(filePath: string, content: string, options?: // Only ENOENT means "there is no target, so there is no DACL to preserve". EACCES or any // other probe error says nothing about the target's security descriptor, and publishing // over a target we could not inspect would replace it without a captured DACL. - const probeCode = err && typeof err === "object" && "code" in err ? (err as { code?: string }).code : undefined + const probeCode = + err && typeof err === "object" && "code" in err ? (err as { code?: string }).code : undefined if (probeCode !== "ENOENT") { throw err } @@ -579,7 +592,7 @@ export async function safeWriteText(filePath: string, content: string, options?: await fs.unlink(backupPath) } catch (cleanupError: unknown) { console.warn( - `safeWriteText: the write to ${targetPath} committed but its backup copy could not be removed at ${backupPath} (${String(cleanupError)}); the copy is left in place.` + `safeWriteText: the write to ${targetPath} committed but its backup copy could not be removed at ${backupPath} (${String(cleanupError)}); the copy is left in place.`, ) } } @@ -636,7 +649,7 @@ export async function safeWriteText(filePath: string, content: string, options?: } if (attempt === 1) { console.warn( - `safeWriteText: staging temp release failed: ${tempPath} (${code ?? String(cleanupError)})` + `safeWriteText: staging temp release failed: ${tempPath} (${code ?? String(cleanupError)})`, ) } } @@ -656,8 +669,7 @@ export async function safeWriteText(filePath: string, content: string, options?: // Raised outside the rollback handler on purpose: the content is committed, so // this is not a failure to roll back, and the backup must survive for recovery. if (durabilityError !== null) { - const reason = - durabilityError instanceof Error ? durabilityError.message : String(durabilityError) + const reason = durabilityError instanceof Error ? durabilityError.message : String(durabilityError) throw new PublishNotDurableError(targetPath, reason) } } From b27221ce475129a819ab19f5bd57b2f493733b71 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:43:17 +0800 Subject: [PATCH 38/60] fix(file-safety): stop the icacls tree walk and the restore that cannot succeed Two Windows-only costs were paid by every safeWriteJson write and every saveDirectly call over an existing file: - icacls /save ran with /T. With a file path, /T walks the whole target directory and saves every file that shares the name (a save of \package.json also walks node_modules), and the matching /restore then rewrites the DACL of every one of them instead of only the target's. An inaccessible subdirectory can also make /save exit non-zero, leaving the fail-closed check dependent on a dump that may not contain the target's entry. - On a host that does not hold the privilege, icacls /restore always exits 1300, so each write spawned one save plus two restores that cannot take effect and warned about it. The first such failure now records the host limitation and the rest of the process skips the save/restore pair, warning once. A transient restore failure still retries and still preserves later writes. Test hygiene in the same area: the DACL and backup blocks use a path-aware statSync stub, because the code stats the target for its mode and the dump to judge the capture, and the two describes outside the main describe now reset their own mocks so a -t run of a single test does not inherit mock state. --- .../__tests__/safeWriteText.spec.ts | 90 +++++++++++++++++-- src/services/file-safety/safeWriteText.ts | 62 +++++++++---- 2 files changed, 132 insertions(+), 20 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 442c27dcb0..edc40462a5 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -253,7 +253,14 @@ describe("safeWriteText", () => { // These tests exercise the win32 publish path, whose DACL capture is judged by its // artifact, so the mock world has to produce a usable dump. beforeEach(() => { - vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 256 })) as never) + // Path-aware: this code stats two different paths - the target, for its mode, and the + // dump, to judge the capture. One stub for both paths would let a regression that + // stats the target instead of the dump pass, and would silently stage with mode 0 + // because the stub carries no mode. + vi.mocked(fsSync.statSync).mockImplementation(((p: unknown) => + typeof p === "string" && p.includes(".acl.tmp") + ? { isFile: () => true, size: 256 } + : _stats(0o644)) as never) }) it("copies target -> backup before commit, deletes backup on success", async () => { const targetPath = "/tmp/test-dir/target.txt" @@ -321,7 +328,14 @@ describe("safeWriteText", () => { // The DACL capture is judged by its artifact, so the mock world has to produce one: // a regular, non-empty dump. Tests that want an unusable artifact override this. beforeEach(() => { - vi.mocked(fsSync.statSync).mockImplementation((() => ({ isFile: () => true, size: 256 })) as never) + // Path-aware: this code stats two different paths - the target, for its mode, and the + // dump, to judge the capture. One stub for both paths would let a regression that + // stats the target instead of the dump pass, and would silently stage with mode 0 + // because the stub carries no mode. + vi.mocked(fsSync.statSync).mockImplementation(((p: unknown) => + typeof p === "string" && p.includes(".acl.tmp") + ? { isFile: () => true, size: 256 } + : _stats(0o644)) as never) }) it.skipIf(process.platform !== "win32")( "copies target DACL onto staging file via icacls before rename on Windows", @@ -337,7 +351,9 @@ describe("safeWriteText", () => { const saveCall = vi.mocked(execFile).mock.calls[0] expect(saveCall[0]).toBe("icacls") - expect(saveCall[1]).toEqual([targetPath, "/save", expect.stringContaining(".acl.tmp"), "/T"]) + // No /T: with a file path it makes icacls walk the whole tree and save every file of + // that name, and the restore then rewrites all of their DACLs. + expect(saveCall[1]).toEqual([targetPath, "/save", expect.stringContaining(".acl.tmp")]) const restoreCall = vi.mocked(execFile).mock.calls[1] expect(restoreCall[0]).toBe("icacls") @@ -534,7 +550,42 @@ describe("safeWriteText", () => { expect(restoreCalls.length).toBeGreaterThan(0) }) - it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { + it("skips the DACL save and restore for the rest of the process once a restore reports the missing privilege", async () => { + // The memo is process-wide, so this test runs against a fresh module instance: + // otherwise the write below would disable DACL handling for every later test in + // the file. vi.mock factories still apply to the re-imported module. + vi.resetModules() + const { safeWriteText: freshWriteText } = await import("../safeWriteText") + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const privilegeFailure = Object.assign(new Error("Access is denied."), { code: 1300 }) + let calls = 0 + const runner = vi.fn((...args: unknown[]) => { + calls++ + const cb = args[args.length - 1] as (err: Error | null) => void + // The save succeeds; every restore reports the missing privilege. + cb(args[1] && String((args[1] as string[])[1]) === "/restore" ? privilegeFailure : null) + }) as unknown as typeof execFile + const options = { platform: "win32", execFileRunner: runner } + + await freshWriteText(targetPath, "data", options) + const callsAfterFirstWrite = calls + expect(callsAfterFirstWrite).toBeGreaterThan(0) + expect(warn).toHaveBeenCalledWith(expect.stringContaining("cannot restore DACLs")) + + calls = 0 + warn.mockClear() + await freshWriteText(targetPath, "data", options) + + expect(calls).toBe(0) + expect(warn).not.toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + warn.mockRestore() + }) + + it("win32 DACL save args are [targetPath, /save, dumpPath] before backup rename", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -547,7 +598,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(".acl.tmp")]) // Second call: restore DACL onto directory after commit rename const secondCall = vi.mocked(execFile).mock.calls[1] @@ -1563,6 +1614,20 @@ describe("resolvePublishTarget symlink cycle (S1)", () => { describe("parent directory creation (safeWriteText.ts:288-290)", () => { beforeEach(() => { + // This block sits outside describe("safeWriteText"), so the resetAllMocks there + // never runs for it: without its own reset it inherits whatever the previous block + // left in the mocks, and a run with -t that selects only these tests gets bare + // vi.fn() stubs (undefined stats) and fails for unrelated reasons. + vi.resetAllMocks() + 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(fsSync.statSync).mockReturnValue(_stats(0o644)) + vi.mocked(fsSync.lstatSync).mockReturnValue(_dirStats()) + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + typeof args[3] === "number" ? args[3] : 0, + ) vi.mocked(fs.realpath).mockResolvedValue("/tmp/test-dir/target.txt") vi.mocked(fs.mkdir).mockResolvedValue(undefined) vi.mocked(fs.access).mockResolvedValue(undefined) @@ -1598,6 +1663,21 @@ describe("parent directory creation (safeWriteText.ts:288-290)", () => { }) describe("partial backup after a failed copy (backup:true)", () => { + beforeEach(() => { + // Same isolation reason as the block above: outside the main describe, so nothing + // resets the mocks or restores the defaults for it. + vi.resetAllMocks() + 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(fsSync.statSync).mockReturnValue(_stats(0o644)) + vi.mocked(fsSync.lstatSync).mockReturnValue(_dirStats()) + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + typeof args[3] === "number" ? args[3] : 0, + ) + }) + it("removes the partial backup when copyFile fails with ENOENT and the publish succeeds", 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 0d39b952f2..80b337168b 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -178,7 +178,13 @@ async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunne const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { - runner("icacls", [srcPath, "/save", dumpPath, "/T"], { windowsHide: true }, (err) => + // No /T: with a file path, /T makes icacls walk the whole directory tree and save every + // file that shares the name (a save of \package.json also walks node_modules), + // and the matching /restore then rewrites the DACL of every one of those files rather + // than only the target's. An inaccessible subdirectory can also make /save exit + // non-zero, leaving the fail-closed check to depend on a dump that may not contain the + // target's own entry. + runner("icacls", [srcPath, "/save", dumpPath], { windowsHide: true }, (err) => err ? reject(err) : resolve(), ) }) @@ -209,13 +215,16 @@ function _dumpIsUsable(dumpPath: string): boolean { } /** Restore a DACL dump onto *dirPath* on Windows. - * Returns true when icacls reported success; false otherwise, so the caller can - * decide whether a lost DACL is tolerable. */ + * `restored` reports whether icacls succeeded; the caller decides whether a lost DACL is + * tolerable. `privilegeUnavailable` separates the one failure that will never fix itself + * from a transient one: on a non-elevated host /restore always exits 1300 (measured), so + * retrying it on every later write costs three process spawns per write for a DACL that + * cannot be restored. A transient failure must not disable preservation for the process. */ async function _restoreDaclWindows( dirPath: string, dumpPath: string, execFileRunner?: typeof execFile, -): Promise { +): Promise<{ restored: boolean; privilegeUnavailable: boolean }> { const runner = execFileRunner ?? execFile try { await new Promise((resolve, reject) => { @@ -223,12 +232,24 @@ async function _restoreDaclWindows( err ? reject(err) : resolve(), ) }) - return true - } catch { - return false + return { restored: true, privilegeUnavailable: false } + } catch (error: unknown) { + // execFile reports a non-zero exit as an error carrying that exit code. + const code = + error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined + return { restored: false, privilegeUnavailable: code === _ICACLS_PRIVILEGE_EXIT_CODE } } } +// Set once a restore has failed with the missing-privilege exit code: from then on the +// process skips the save/restore pair instead of paying for it on every write and warning +// about the same host limitation each time. The DACL is then simply not preserved - the +// outcome these writes already had - and the reason is logged once, when the memo is set. +let daclRestorePrivilegeUnavailable = false + +// icacls exit code for "a required privilege is not held by the client". +const _ICACLS_PRIVILEGE_EXIT_CODE = 1300 + // -- public API ------------------------------------------------------------ /** @@ -350,6 +371,8 @@ export async function safeWriteText(filePath: string, content: string, options?: let daclDumpPath: string | null = null // tracked for cleanup in finally let daclSaved = false // restore step runs only when the save succeeded + const platform = options?.platform ?? process.platform + try { // -- Step 1: write content to staging temp file ------------------- if (!options?.tempPath) { @@ -426,8 +449,10 @@ export async function safeWriteText(filePath: string, content: string, options?: } // -- Step 2 (win32): save DACL BEFORE backup rename --------------- - const platform = options?.platform ?? process.platform - if (platform === "win32") { + // Once a restore has proven this host cannot restore at all, saving a dump it cannot + // apply is three process spawns per write for nothing, so the pair is skipped. The + // skip is decided here, not in step 5, because the save is the expensive half. + if (platform === "win32" && !daclRestorePrivilegeUnavailable) { try { await fs.access(targetPath) // target exists? // Unique per call: two concurrent writes to the same target must not share one dump, @@ -557,14 +582,21 @@ export async function safeWriteText(filePath: string, content: string, options?: // -- Step 5 (win32): restore DACL AFTER commit rename --------- if (platform === "win32" && daclSaved && daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) - let restored = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) - if (!restored) { - // One retry: icacls can fail transiently while another process still holds - // the just-renamed file open. - restored = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + let attempt = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + if (!attempt.restored && !attempt.privilegeUnavailable) { + // One retry, and only for a failure that could be transient: icacls can fail + // while another process still holds the just-renamed file open. A host without + // the privilege fails identically every time, so retrying it is pure cost. + attempt = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) } - if (!restored) { + if (!attempt.restored) { daclRestoreFailed = true + if (attempt.privilegeUnavailable && !daclRestorePrivilegeUnavailable) { + daclRestorePrivilegeUnavailable = true + console.warn( + "safeWriteText: this host cannot restore DACLs (icacls /restore reports a missing privilege), so later writes in this process will skip saving and restoring them.", + ) + } // Not fatal for the content: on a non-elevated host /restore cannot succeed at // all, and the new content is already committed. It is fatal for the recovery // state, so the backup is kept below and the situation is surfaced here. From 782dc5a018aaed2b3165d14bdef21e1381e99632 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:46:16 +0800 Subject: [PATCH 39/60] fix(file-safety): keep the target's group when publishing over it The commit is a rename, so the published inode is a new one: it takes this process's primary group, or the directory's gid when the directory is setgid. A shared 0o664 file owned by group devs therefore lost group write access for every member whose primary group is not devs after one direct save, even though fchmodSync restored the mode - the same regression the mode fix exists to prevent, so the group has to survive too. The gid is taken from the same stat call that reads the mode and applied to the descriptor with fchownSync(fd, -1, gid) before the chmod, because chown can clear the setgid bits the chmod has just set. It is best effort: the call only succeeds when this process belongs to that group, which is the case that matters, and the mode preservation still stands when it does not. Both the staging branch and the caller-staged branch do it. Windows is skipped: it carries no POSIX gid to preserve, its identity is the DACL. --- .../__tests__/safeWriteText.spec.ts | 62 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 37 ++++++++++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index edc40462a5..f4547ccbbf 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -40,6 +40,7 @@ vi.mock("fs", () => ({ fsyncSync: vi.fn(), chmodSync: vi.fn(), fchmodSync: vi.fn(), + fchownSync: vi.fn(), statSync: vi.fn(), lstatSync: vi.fn(), rmdirSync: vi.fn(), @@ -88,10 +89,10 @@ function _linkStats(): fsSync.Stats { return s } -// Minimal Stats stand-in: the SUT only reads `.mode` from it. -function _stats(mode: number): fsSync.Stats { +// Minimal Stats stand-in: the SUT reads `.mode` and, on POSIX, `.gid` from it. +function _stats(mode: number, gid?: number): fsSync.Stats { const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats - Object.assign(s, { mode }) + Object.assign(s, gid === undefined ? { mode } : { mode, gid }) return s } @@ -1142,6 +1143,61 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) + it("keeps the target's group on the staged file before publishing over it", async () => { + // The commit is a rename, so the new inode would otherwise take this process's + // primary group and a shared 0o664 group-owned file would lose group write access + // even with the mode restored. Order matters: chown can clear setgid bits, so it + // has to run before the chmod. + 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(0o664, 4321)) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + expect(fsSync.fchownSync).toHaveBeenCalledWith(1, -1, 4321) + expect(fsSync.fchmodSync).toHaveBeenCalledWith(1, 0o664) + expect(vi.mocked(fsSync.fchownSync).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0], + ) + }) + + it("keeps the target's group when the caller staged the temp file", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(7) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o664, 4321)) + + await safeWriteText(targetPath, "data", { platform: "linux", tempPath: "/tmp/test-dir/caller.tmp" }) + + expect(fsSync.fchownSync).toHaveBeenCalledWith(7, -1, 4321) + expect(fsSync.fchmodSync).toHaveBeenCalledWith(7, 0o664) + expect(vi.mocked(fsSync.fchownSync).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0], + ) + }) + + it("does not attempt a group change on a platform whose identity is the DACL", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // Windows carries no POSIX gid to preserve - its identity is the DACL, which the + // save/restore steps own - so the DACL path has to stay reachable here. + vi.mocked(fsSync.statSync).mockImplementation(((p: unknown) => + typeof p === "string" && p.includes(".acl.tmp") + ? { isFile: () => true, size: 256 } + : _stats(0o664, 4321)) as never) + vi.mocked(execFile).mockImplementation(((...args: unknown[]) => { + const cb = args[args.length - 1] as (err: Error | null) => void + cb(null) + }) as never) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + expect(fsSync.fchownSync).not.toHaveBeenCalled() + expect(fsSync.fchmodSync).toHaveBeenCalledWith(1, 0o664) + }) + 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) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 80b337168b..eb1ad50dac 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -170,6 +170,31 @@ function _fsyncFile(fd: number): void { fsSync.fsyncSync(fd) } +/** + * Keep the existing target's group on the staged file before it is published. + * + * The commit is a rename, so the published inode is a new one: it takes the process's + * primary group (or the directory's gid when the directory is setgid). A shared 0o664 + * file owned by group `devs` would therefore lose group write access for every member + * whose primary group is not `devs`, even though the fchmod below restores the mode - the + * mode fix exists precisely to stop that regression, so the group has to survive too. + * + * Best-effort: fchown only succeeds when this process belongs to that group, which is the + * case that matters; anywhere else the mode fix still stands. It runs BEFORE fchmod + * because chown can clear the setgid bits that chmod has just set. Windows carries no + * POSIX gid to preserve - its identity is the DACL, handled by the save/restore steps. + */ +function _applyTargetOwnership(fd: number, gid: number | undefined, platform: string): void { + if (platform === "win32" || typeof gid !== "number") { + return + } + try { + fsSync.fchownSync(fd, -1, gid) + } catch { + // Not a member of the target's group: keep the mode preservation and move on. + } +} + /** Save the DACL of *srcPath* to a dump file on Windows. * Returns true when a usable dump exists; false otherwise. * Never throws - a false return means no usable dump was produced, which the caller treats @@ -380,9 +405,12 @@ export async function safeWriteText(filePath: string, content: string, options?: // not be published wider than the file it replaces (a 0o600 target // must not become 0o644 through the atomic rename). let targetMode = 0o644 // default for a fresh target + let targetGid: number | undefined let targetExists = false try { - targetMode = fsSync.statSync(targetPath).mode & 0o777 + const targetStats = fsSync.statSync(targetPath) + targetMode = targetStats.mode & 0o777 + targetGid = targetStats.gid targetExists = true } catch (error: unknown) { // Only a genuinely absent target may take the default mode. Any other @@ -408,6 +436,7 @@ export async function safeWriteText(filePath: string, content: string, options?: // also what the caller-staged branch below does. try { if (targetExists) { + _applyTargetOwnership(fd, targetGid, platform) fsSync.fchmodSync(fd, targetMode) } // Loop until every byte is written: writeSync can report a short @@ -430,8 +459,11 @@ export async function safeWriteText(filePath: string, content: string, options?: // 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 + let targetGid: number | undefined try { - targetMode = fsSync.statSync(targetPath).mode & 0o777 + const targetStats = fsSync.statSync(targetPath) + targetMode = targetStats.mode & 0o777 + targetGid = targetStats.gid } catch (error: unknown) { // As above: an unknown target mode must not be replaced by the temp's // own creation mode, which can be wider than the target's. @@ -440,6 +472,7 @@ export async function safeWriteText(filePath: string, content: string, options?: const fd = fsSync.openSync(tempPath, "r+") try { if (targetMode !== null) { + _applyTargetOwnership(fd, targetGid, platform) fsSync.fchmodSync(fd, targetMode) } _fsyncFile(fd) From de2f868cd7ae057bc393b5d88515f1920f4531e5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:46:17 +0800 Subject: [PATCH 40/60] test(file-safety,editor): name the retained path, catch the dump, assert the result - The persistent staging-release failure test captures the staged path from the publish call and asserts that both unlink attempts and the warning name that exact path, instead of counting attempts and matching a message prefix. - The real-filesystem residue filter now also matches the Windows DACL dump, which is written next to the target and only ever appears on the windows-latest run, so a leaked dump can no longer pass a suite whose claim is the absence of residue. - The saveDirectly missing-target test asserts the returned finalContent, so a stale result cannot pass on call counts alone. --- .../editor/__tests__/DiffViewProvider.spec.ts | 6 +++++- .../safeWriteText.integration.spec.ts | 18 +++++++++++++++--- .../__tests__/safeWriteText.spec.ts | 19 ++++++++++++++----- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 37bd61041d..2b67b52eaa 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -802,9 +802,13 @@ describe("DiffViewProvider", () => { vi.mocked(safeWriteText).mockClear() vi.mocked(fs.access).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) - await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200) + const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200) expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + // Call counts alone would still pass if the publish ran but the caller reported + // nothing about the content it wrote. + expect(result.finalContent).toBe("new content") + expect(result.userEdits).toBeUndefined() vi.mocked(fs.access).mockResolvedValue(undefined) }) diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts index 58db29d832..77bdd9e633 100644 --- a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -22,9 +22,21 @@ describe("safeWriteText against a real filesystem", () => { }) function _leftovers(): string[] { - return fsSync - .readdirSync(dir) - .filter((name) => name.includes(".file-safety-staging") || name.includes(".new_") || name.includes(".bak")) + return ( + fsSync + .readdirSync(dir) + // The Windows DACL dump is written next to the target and only ever shows up on the + // windows-latest run, so a filter that misses it lets a leaked dump pass a suite + // whose claim is the absence of residue. + .filter(function (name) { + return ( + name.includes(".file-safety-staging") || + name.includes(".new_") || + name.includes(".bak") || + name.includes(".acl.tmp") + ) + }) + ) } it("creates a new file with the requested bytes and leaves no residue", async () => { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index f4547ccbbf..45909cb05b 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -840,11 +840,20 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "data")).rejects.toThrow("EBUSY rename") expect(stagingUnlinks).toBe(2) - expect( - warn.mock.calls.filter(function (call) { - return String(call[0]).includes("staging temp release failed") - }).length, - ).toBe(1) + // The retry exists so the reader learns WHICH path was retained, so the assertions + // have to name it: counting attempts and matching a prefix would still pass if the + // warning reported a different path than the one being unlinked. + const stagedPath = String(vi.mocked(fs.rename).mock.calls[0]?.[0]) + expect(stagedPath).toContain(".file-safety-staging") + const unlinkAttempts = vi.mocked(fs.unlink).mock.calls.filter(function (call) { + return String(call[0]) === stagedPath + }) + expect(unlinkAttempts.length).toBe(2) + const releaseWarnings = warn.mock.calls.filter(function (call) { + return String(call[0]).includes("staging temp release failed") + }) + expect(releaseWarnings.length).toBe(1) + expect(String(releaseWarnings[0]?.[0])).toContain(stagedPath) warn.mockRestore() }) From 7d62bfa3e963ce728e937aa76d80f40e287ff138 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:22:21 +0800 Subject: [PATCH 41/60] test(file-safety): assert the exact icacls count in the privilege-memo test The first write makes exactly one /save and one /restore: the 1300 exit tells the restore it is the missing privilege, so the transient retry must not run. A greater-than-zero assertion could not tell that apart; letting a privilege failure take the retry as well now reddens exactly this test. --- src/services/file-safety/__tests__/safeWriteText.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 45909cb05b..d109e5627c 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -572,8 +572,9 @@ describe("safeWriteText", () => { const options = { platform: "win32", execFileRunner: runner } await freshWriteText(targetPath, "data", options) - const callsAfterFirstWrite = calls - expect(callsAfterFirstWrite).toBeGreaterThan(0) + // Exactly one save and one restore: the 1300 exit tells the restore it is + // the missing privilege, so the transient retry must not run. + expect(calls).toBe(2) expect(warn).toHaveBeenCalledWith(expect.stringContaining("cannot restore DACLs")) calls = 0 From faf666d18748a026f218c9098ba27cfa6a601f0f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 09:22:07 +0800 Subject: [PATCH 42/60] feat(file-safety): report a lost DACL restore as DaclRestoreError after the commit Convergence port of the chain's first-unit shape into this branch's diverged safeWriteText copy, so the Security Boundaries row at DiffViewProvider.ts:1174 can clear without adding a second mechanism at the call site. - safeWriteText: a DACL restore that fails after the commit rename now records a typed DaclRestoreError and is raised after the rollback handler, the same position PublishNotDurableError already uses: the content is committed, so this is not a failure to roll back, and the backup copy - the only artifact still carrying the target's original security descriptor - is retained. Warn-and-resolve is gone: no caller can observe success for a publish whose DACL was not restored. The missing-privilege memo keeps its semantics; the first publish that loses its DACL still reports the typed error. - Capture stays fail-closed before the commit (DaclCaptureError unchanged); the capture-abort test now also pins that the commit rename never ran. - safeWriteJson: DaclRestoreError joins PublishNotDurableError in the commit-landed classification, so the landed publish's staged path is not treated as a leftover temp file, and the error still rethrows. - saveDirectly needs no production change: it awaits the primitive directly, so the typed error surfaces on the direct-save path; a new spec pins that. Tests pin the four contract points with negative controls (mutants restored byte-for-byte): typed error to the caller - 4 tests red under the throw mutant, 0 red in the pre-fix spec; backup retention on restore failure - 1 red, already pinned pre-fix, no regression; direct-save surfacing - 1 red, 0 red pre-fix; fail-closed capture - 7 red, pinned pre-fix and now. Local Windows note: icacls /restore exits 1300 on this non-elevated host, so the two real-icacls integration tests (safeWriteText.integration.spec.ts "replaces an existing file and leaves no residue", safeWriteJson.test.ts "should successfully write a new file") now report DaclRestoreError locally where they previously only warned; CI windows-latest runs elevated and is the arbiter, as with the first unit's equivalent suite. --- .../editor/__tests__/DiffViewProvider.spec.ts | 34 ++++++++-- .../__tests__/safeWriteText.spec.ts | 60 +++++++++++++----- src/services/file-safety/safeWriteText.ts | 62 ++++++++++++++----- 3 files changed, 121 insertions(+), 35 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 2b67b52eaa..97a6e83224 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -23,10 +23,16 @@ vi.mock("fs/promises", () => ({ unlink: vi.fn().mockResolvedValue(undefined), })) -// Mock safeWriteText (used by saveDirectly) -vi.mock("../../../services/file-safety/safeWriteText", () => ({ - safeWriteText: vi.fn().mockResolvedValue(undefined), -})) +// Mock safeWriteText (used by saveDirectly). The real module's error classes stay +// available through importOriginal, so a test can hand the caller an actual +// DaclRestoreError instead of a look-alike. +vi.mock("../../../services/file-safety/safeWriteText", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + safeWriteText: vi.fn().mockResolvedValue(undefined), + } +}) // Mock utils vi.mock("../../../utils/fs", () => ({ @@ -840,6 +846,26 @@ describe("DiffViewProvider", () => { ) }) + it("surfaces a failed DACL restore as a rejection instead of a completed save", async () => { + const { DaclRestoreError, safeWriteText } = await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockClear() + // The primitive reports a lost DACL after the commit rename as DaclRestoreError. + // The direct-save path must not swallow it: a caller that sees saveDirectly + // resolve would report a save whose access rights silently changed. + vi.mocked(safeWriteText).mockRejectedValueOnce(new DaclRestoreError(`${mockCwd}/test.ts`, null)) + + await expect( + diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200), + ).rejects.toBeInstanceOf(DaclRestoreError) + + // The rejection is the primitive's own - the publish was attempted exactly once, + // not retried into a success the caller could observe. + const publishCalls = vi.mocked(safeWriteText).mock.calls.filter(function (call) { + return call[0] === `${mockCwd}/test.ts` + }) + expect(publishCalls).toHaveLength(1) + }) + beforeEach(() => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue(makeTextEditor()) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index d109e5627c..1bf43f9dfa 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -4,7 +4,7 @@ import { execFile } from "child_process" import type { ChildProcess } from "child_process" import * as path from "path" -import { resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" +import { DaclRestoreError, resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" // The two failure classes are module-private (knip ignores __tests__, so a // test-only export would be reported as unused), so tests match them by name. @@ -522,6 +522,8 @@ describe("safeWriteText", () => { // Fail closed: the target is never published, because publishing would replace its security // descriptor with inherited permissions and nothing could put the original back. + // The refusal lands before the commit rename - the target keeps its content. + expect(fs.rename).not.toHaveBeenCalled() // Only the save ran: a failed capture must not be followed by a restore attempt. expect(execFile).toHaveBeenCalledTimes(1) // The staging file and any partial dump are still cleaned up by the rollback. @@ -540,9 +542,12 @@ describe("safeWriteText", () => { }) vi.mocked(fsSync.statSync).mockReturnValue({ isFile: () => true, size: 256 } as never) - await safeWriteText(targetPath, "data", { platform: "win32" }) - - // The capture is judged by the artifact, so the publish proceeds and the restore is attempted. + // The capture is judged by the artifact, so the publish proceeds and the restore is + // attempted. This mocked icacls fails the restore too, and a failed restore reaches + // the caller as DaclRestoreError rather than a warning beside a resolved write. + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toBeInstanceOf( + DaclRestoreError, + ) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) // Save, then the restore attempt (icacls is retried once if it fails, so >= 2). const restoreCalls = vi.mocked(execFile).mock.calls.filter(function (call) { @@ -556,7 +561,10 @@ describe("safeWriteText", () => { // otherwise the write below would disable DACL handling for every later test in // the file. vi.mock factories still apply to the re-imported module. vi.resetModules() - const { safeWriteText: freshWriteText } = await import("../safeWriteText") + // The re-imported module has its own class identity, so the rejection is matched + // against the class this instance throws, not the one imported at the top. + const { safeWriteText: freshWriteText, DaclRestoreError: FreshDaclRestoreError } = + await import("../safeWriteText") const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -571,7 +579,9 @@ describe("safeWriteText", () => { }) as unknown as typeof execFile const options = { platform: "win32", execFileRunner: runner } - await freshWriteText(targetPath, "data", options) + // The first publish still loses its DACL, so it is reported as the typed error - + // the memo only changes what LATER writes in this process pay for. + await expect(freshWriteText(targetPath, "data", options)).rejects.toBeInstanceOf(FreshDaclRestoreError) // Exactly one save and one restore: the 1300 exit tells the restore it is // the missing privilege, so the transient retry must not run. expect(calls).toBe(2) @@ -635,12 +645,13 @@ describe("safeWriteText", () => { expect(dumps[1]).toContain("safeWriteText.acl.tmp") }) - it("win32 DACL: dump is unlinked even when restore fails", async () => { + it("win32 DACL: a failed restore is a DaclRestoreError 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 + // icacls save succeeds, both restore attempts fail let callCount = 0 vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { callCount++ @@ -650,10 +661,20 @@ describe("safeWriteText", () => { return fakeChild }) - await safeWriteText(targetPath, "data", { platform: "win32" }) + // A publish whose DACL was not restored reaches the caller as its own error: + // no caller can observe success for it. + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toBeInstanceOf( + DaclRestoreError, + ) - // write succeeded despite restore failure (best-effort) - expect(fs.rename).toHaveBeenCalled() + // The content did commit before the restore failed: the rename happened exactly + // once even though the publish reports an error. + expect(fs.rename).toHaveBeenCalledTimes(1) + + // Contract change: the changed access rights are an error the caller receives, + // not a warning beside a write that resolved. + expect(warnSpy.mock.calls.flat().join(" ")).not.toContain("could not be restored") + warnSpy.mockRestore() // dump file was still unlinked in finally expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) @@ -788,15 +809,22 @@ describe("safeWriteText", () => { return fakeChild }) - await safeWriteText(targetPath, "data", { platform: "win32", backup: true }) + // The publish reports the lost DACL as the typed error... + await expect(safeWriteText(targetPath, "data", { platform: "win32", backup: true })).rejects.toBeInstanceOf( + DaclRestoreError, + ) - // The content is still committed - a failed ACL restore is not a write failure. + // ...but the content is still committed - the restore failure lands after the rename. expect(fs.rename).toHaveBeenCalled() // The dump is still cleaned up in the finally block. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) - // But the backup survives: it is the only artifact that still carries the target's - // original security descriptor, so deleting it would destroy the recovery path. - expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining(".bak")) + // And the backup survives the rejection: it is the only artifact that still carries + // the target's original security descriptor, so deleting it would destroy the + // recovery path. + const bakUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call) { + return typeof call[0] === "string" && call[0].includes(".bak") + }) + expect(bakUnlinks).toHaveLength(0) }) it("staging temp release: a transient unlink failure on the rollback path is retried", async () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index eb1ad50dac..a477b3df5e 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -11,8 +11,9 @@ export interface SafeWriteTextOptions { * When true, copy the target to a backup file before the commit rename. The * commit is a single atomic rename, so the target is never removed first and * nothing restores from the copy: it is deleted after a confirmed commit, and - * kept on disk when the commit could not be confirmed durable so the previous - * content stays recoverable. Costs a full read+write of the old file, so + * kept on disk when the commit could not be confirmed durable or the saved + * DACL could not be restored, so the previous content and its security + * descriptor stay recoverable. Costs a full read+write of the old file, so * callers that do not need that recovery copy should leave this off. */ backup?: boolean @@ -90,6 +91,22 @@ export class PublishNotDurableError extends Error { } } +/** + * The content is committed but the saved DACL could not be put back on it, so the file at the + * target answers to different access rights than the one it replaced. Reported as an error rather + * than a warning: the caller has to know that the publish changed who can read the file. Raised + * after the commit rename, so the content is at the target and the backup copy is retained. + */ +export class DaclRestoreError extends Error { + constructor( + public readonly targetPath: string, + public readonly causeError: unknown, + ) { + super(`safeWriteText: content committed at ${targetPath}, but its saved access rights could not be restored`) + this.name = "DaclRestoreError" + } +} + function _errorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code @@ -393,6 +410,11 @@ export async function safeWriteText(filePath: string, content: string, options?: // content is in place, so there is nothing to roll back, but the caller must // not be told the publish is durable. let durabilityError: unknown = null + // Set when the commit landed but the saved DACL could not be put back on the published file. + // The content is in place, so there is nothing to roll back, but the caller must not observe a + // success whose access rights changed, and the backup is the only artifact still carrying the + // original security descriptor, so it has to survive for recovery. + let daclRestoreError: DaclRestoreError | null = null let daclDumpPath: string | null = null // tracked for cleanup in finally let daclSaved = false // restore step runs only when the save succeeded @@ -607,11 +629,6 @@ export async function safeWriteText(filePath: string, content: string, options?: } } - // Tracked separately from durability: a committed file whose DACL could not be - // restored is only half-published, and the backup is the only copy that still carries - // the original security descriptor. - let daclRestoreFailed = false - // -- Step 5 (win32): restore DACL AFTER commit rename --------- if (platform === "win32" && daclSaved && daclDumpPath !== null) { const restoredDir = path.dirname(targetPath) @@ -623,19 +640,20 @@ export async function safeWriteText(filePath: string, content: string, options?: attempt = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) } if (!attempt.restored) { - daclRestoreFailed = true if (attempt.privilegeUnavailable && !daclRestorePrivilegeUnavailable) { daclRestorePrivilegeUnavailable = true console.warn( "safeWriteText: this host cannot restore DACLs (icacls /restore reports a missing privilege), so later writes in this process will skip saving and restoring them.", ) } - // Not fatal for the content: on a non-elevated host /restore cannot succeed at - // all, and the new content is already committed. It is fatal for the recovery - // state, so the backup is kept below and the situation is surfaced here. - console.warn( - `safeWriteText: ${targetPath} was published but its original DACL could not be restored from ${daclDumpPath ?? "the saved dump"}; the previous content and its permissions are only recoverable from the backup copy.`, - ) + // The content is committed, but the published file answers to a different DACL + // than the one that was saved, and nothing here verified an equivalent + // restrictive ACL on the replacement. Reported as an error rather than a + // warning: a publish that changed who can read the file must not look like an + // ordinary successful save. Thrown after the rollback handler below - the + // content is committed, so this is not a failure to roll back, and the backup + // has to survive for recovery. + daclRestoreError = new DaclRestoreError(targetPath, null) } } @@ -646,7 +664,12 @@ export async function safeWriteText(filePath: string, content: string, options?: // A copy that failed part way leaves a partial file at backupPath with backupCreated // never set, so the condition has to cover backupAttempted too: after a successful // publish nothing else would ever remove that half-written backup. - if ((backupCreated || backupAttempted) && backupPath && durabilityError === null && !daclRestoreFailed) { + if ( + (backupCreated || backupAttempted) && + backupPath && + durabilityError === null && + daclRestoreError === null + ) { try { await fs.unlink(backupPath) } catch { @@ -737,4 +760,13 @@ export async function safeWriteText(filePath: string, content: string, options?: const reason = durabilityError instanceof Error ? durabilityError.message : String(durabilityError) throw new PublishNotDurableError(targetPath, reason) } + + // Raised outside the rollback handler for the same reason as PublishNotDurableError: the + // content is committed, so this is not a failure to roll back, and the backup retained above + // is the only artifact still carrying the target's original security descriptor. A restore + // failure therefore reaches the caller as its own error: no caller can observe success for a + // publish whose DACL was not restored. + if (daclRestoreError !== null) { + throw daclRestoreError + } } From 0390c1ff1f6b43a2af13d7dbbad7ab4743d8c496 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 16:10:43 +0800 Subject: [PATCH 43/60] test(file-safety): make the DACL backup and leftover-temp assertions describe the state they claim Two review findings said the same thing about different tests: an assertion that counts the ABSENCE of an unlink passes just as well when the artifact was never created, so neither test proved the fact it named. The backup-retention test counted unlinks whose path contained ".bak". With no backup copy at all that count is zero too, so the test passed on a publish that never made the backup it was claiming to keep. It now captures the destination passed to the copy call, asserts the copy really copied the target to that destination, and only then asserts that nothing unlinked that destination. The safeWriteJson leftover test mocked a DaclRestoreError thrown without the commit rename having happened, so it described a write that never committed - where unlinking the staged file would be correct - while claiming to cover the post-commit case. The mock now performs the rename the real failure arrives behind, and the test asserts the committed bytes are at the target and the staged name is gone before asserting the catch path leaves it alone. Negative controls: skipping the backup copy left the old backup-retention assertion green and reddens the new one; dropping the DaclRestoreError arm of the catch classification reddens the leftover test in both shapes; running the mock without the commit reddens the new post-commit assertions. --- .../file-safety/__tests__/safeWriteText.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 1bf43f9dfa..8bdbe3d744 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -820,9 +820,16 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) // And the backup survives the rejection: it is the only artifact that still carries // the target's original security descriptor, so deleting it would destroy the - // recovery path. + // recovery path. The copy is asserted first: counting the absence of a .bak unlink + // also passes when no backup was ever created, which is not the same fact. + const copies = vi.mocked(fs.copyFile).mock.calls + expect(copies).toHaveLength(1) + const [copiedSource, copiedDestination] = copies[0] ?? [] + expect(String(copiedSource)).toBe(targetPath) + const backupDestination = String(copiedDestination) + expect(backupDestination).toContain("safeWriteText.bak_") const bakUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call) { - return typeof call[0] === "string" && call[0].includes(".bak") + return String(call[0]) === backupDestination }) expect(bakUnlinks).toHaveLength(0) }) From 9f3eab72072650addb57727adef6cae9c2b7c83a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 16:18:49 +0800 Subject: [PATCH 44/60] fix(file-safety): report a publish that ran with DACL restoration disabled Once a restore exits 1300 the process memo disables the save/restore pair, and the skip also silenced the condition it was skipping. A later write to an existing target still renames a staged file over it, and a rename does not hand the published file the descriptor of the file it replaced, so those writes replaced the target's access rights, preserved nothing, and resolved as an ordinary successful save. The module's own contract says the opposite: no caller may observe success for a publish whose DACL was not restored. The memo is a cost decision, so it stays a cost decision. Step 2b probes the target with one access() - no process spawn, so the memo still buys zero icacls calls per write - and records that a descriptor is about to be replaced with nothing saved for it. Step 5b turns that into the post-commit error a failed restore already uses, worded for the condition it reports, before step 6 so the backup copy that still carries the original descriptor is retained. A brand-new target is not a loss - it takes a default descriptor either way - so the report is scoped to writes that replaced something, and a non-ENOENT probe error still propagates instead of being read as permission to succeed. The narrowing from the earlier round is untouched: the memo is still set only by exit 1300, so a transient restore failure still retries and still captures and restores the DACL on later writes. Reported through the existing error type rather than a new one on purpose. The caller-side fact is identical - the content landed and the access rights changed - and safeWriteJson already classifies DaclRestoreError as a landed commit it must not unlink, while DiffViewProvider surfaces it. A second class would have to be added to that classification for no change in what any caller can do. --- .../__tests__/safeWriteText.spec.ts | 109 +++++++++++++++++- src/services/file-safety/safeWriteText.ts | 48 +++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 8bdbe3d744..d561a16797 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -17,6 +17,24 @@ async function _rejectionName(promise: Promise): Promise { } } +/** + * Runs a publish exactly once and reports both halves of what a caller observes: the + * rejection's class name and its wording. Needed where the report is the fact under test - + * which condition a post-commit error names is what separates "this host cannot restore" + * from "the restore failed this time" - and a second run would not be the same publish. + */ +async function _rejectionReport(promise: Promise): Promise<{ name: string; message: string }> { + try { + await promise + return { name: "resolved", message: "" } + } catch (error: unknown) { + return { + name: error instanceof Error ? error.name : String(error), + message: error instanceof Error ? error.message : String(error), + } + } +} + // Full mock for fs/promises — all methods are vi.fn() stubs vi.mock("fs/promises", () => ({ mkdir: vi.fn(), @@ -589,7 +607,9 @@ describe("safeWriteText", () => { calls = 0 warn.mockClear() - await freshWriteText(targetPath, "data", options) + // The memo buys the zero-spawn path; it does not buy a silent success. This write + // still replaced the target's descriptor, so it is still reported. + await expect(freshWriteText(targetPath, "data", options)).rejects.toBeInstanceOf(FreshDaclRestoreError) expect(calls).toBe(0) expect(warn).not.toHaveBeenCalled() @@ -597,6 +617,93 @@ describe("safeWriteText", () => { warn.mockRestore() }) + it("win32 DACL: a later write that ran with restoration disabled reports the lost DACL", async () => { + // The memo skips the save/restore pair to save the process spawns. It must not also + // change the answer: the commit is a rename, so a later write over an existing target + // still hands the path a file created with a different descriptor, and with the pair + // skipped nothing saved the old one and nothing can put it back. Resolving quietly + // would tell the caller the access rights are unchanged when they are not. + vi.resetModules() + const { safeWriteText: freshWriteText, DaclRestoreError: FreshDaclRestoreError } = + await import("../safeWriteText") + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const privilegeFailure = Object.assign(new Error("Access is denied."), { code: 1300 }) + let calls = 0 + const runner = vi.fn((...args: unknown[]) => { + calls++ + const cb = args[args.length - 1] as (err: Error | null) => void + cb(args[1] && String((args[1] as string[])[1]) === "/restore" ? privilegeFailure : null) + }) as unknown as typeof execFile + const options = { platform: "win32", execFileRunner: runner, backup: true } + + // First write: capture ran, restore reported 1300, memo set, own loss reported. + await expect(freshWriteText(targetPath, "data", options)).rejects.toBeInstanceOf(FreshDaclRestoreError) + + calls = 0 + vi.mocked(fs.copyFile).mockClear() + vi.mocked(fs.unlink).mockClear() + const second = await _rejectionReport(freshWriteText(targetPath, "data", options)) + + // The condition reaches the caller as the same post-commit error a failed restore + // uses, instead of a resolved write, and the report names the condition it is + // reporting: a reader must be able to tell 'this host cannot restore' apart from + // 'the restore command failed this time'. + expect(second.name).toBe("DaclRestoreError") + expect(second.message).toContain("restoration is disabled") + // Restoration stayed disabled: the memo still buys zero process spawns. + expect(calls).toBe(0) + // The content is still committed - a landed publish whose access rights changed, + // not a refused write. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + // And the backup copy, now the only artifact carrying the original descriptor, was + // really created and really retained. + const copies = vi.mocked(fs.copyFile).mock.calls + expect(copies).toHaveLength(1) + expect(String(copies[0]?.[0])).toBe(targetPath) + const backupDestination = String(copies[0]?.[1]) + expect( + vi.mocked(fs.unlink).mock.calls.filter((call) => String(call[0]) === backupDestination), + ).toHaveLength(0) + warn.mockRestore() + }) + + it("win32 DACL: restoration disabled does not reject a write to a brand-new target", async () => { + // A file that did not exist has no descriptor to lose - it takes a default one at + // creation either way. Reporting the condition there would fail ordinary saves on + // every un-elevated Windows host, so the report is scoped to replaced targets. + vi.resetModules() + const { safeWriteText: freshWriteText, DaclRestoreError: FreshDaclRestoreError } = + await import("../safeWriteText") + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const privilegeFailure = Object.assign(new Error("Access is denied."), { code: 1300 }) + const runner = vi.fn((...args: unknown[]) => { + const cb = args[args.length - 1] as (err: Error | null) => void + cb(args[1] && String((args[1] as string[])[1]) === "/restore" ? privilegeFailure : null) + }) as unknown as typeof execFile + const options = { platform: "win32", execFileRunner: runner } + + await expect(freshWriteText(targetPath, "data", options)).rejects.toBeInstanceOf(FreshDaclRestoreError) + + // From here the target is gone: the step 2b probe must read ENOENT as "nothing to + // preserve" and let the publish resolve. + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + } + return undefined + }) + + await expect(freshWriteText(targetPath, "data", options)).resolves.toBeUndefined() + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + warn.mockRestore() + }) + it("win32 DACL save args are [targetPath, /save, dumpPath] before backup rename", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index a477b3df5e..500209d3a1 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -101,8 +101,9 @@ export class DaclRestoreError extends Error { constructor( public readonly targetPath: string, public readonly causeError: unknown, + reason: string = "its saved access rights could not be restored", ) { - super(`safeWriteText: content committed at ${targetPath}, but its saved access rights could not be restored`) + super(`safeWriteText: content committed at ${targetPath}, but ${reason}`) this.name = "DaclRestoreError" } } @@ -285,8 +286,10 @@ async function _restoreDaclWindows( // Set once a restore has failed with the missing-privilege exit code: from then on the // process skips the save/restore pair instead of paying for it on every write and warning -// about the same host limitation each time. The DACL is then simply not preserved - the -// outcome these writes already had - and the reason is logged once, when the memo is set. +// about the same host limitation each time. Skipping the pair is a cost decision only: it +// must not change what a publish reports. A later write that replaces an existing target +// still loses that target's descriptor, so step 2b records it and the call reports the +// condition instead of resolving quietly. The reason is logged once, when the memo is set. let daclRestorePrivilegeUnavailable = false // icacls exit code for "a required privilege is not held by the client". @@ -417,6 +420,12 @@ export async function safeWriteText(filePath: string, content: string, options?: let daclRestoreError: DaclRestoreError | null = null let daclDumpPath: string | null = null // tracked for cleanup in finally let daclSaved = false // restore step runs only when the save succeeded + // Set when restoration is disabled for this process and this write replaces an existing + // target. The commit is a rename, and a rename does not hand the target the descriptor of + // the file it replaced, so the published file answers to inherited access rights that + // nothing saved and nothing can put back. Reported after the commit like a failed restore: + // the content is committed, so this is not a failure to roll back. + let daclPreservationSkipped = false const platform = options?.platform ?? process.platform @@ -570,6 +579,22 @@ export async function safeWriteText(filePath: string, content: string, options?: } daclDumpPath = null } + } else if (platform === "win32") { + // -- Step 2b (win32): restoration is disabled for this process ---------------- + // The memo skips the save/restore pair to save the process spawns, but the commit is + // still a rename over whatever sits at the target, and a rename does not hand the + // published file the descriptor of the file it replaced. A brand-new file takes a + // default descriptor and loses nothing, so only an existing target is a real loss - + // and that loss has to reach the caller instead of resolving quietly. One probe, no + // process spawn: the memo keeps its cost saving. + try { + await fs.access(targetPath) + daclPreservationSkipped = true + } catch (probeError: unknown) { + // Same rule as the capture probe: only ENOENT proves there is no descriptor to + // preserve. An unreadable target is not evidence that a plain success is safe. + if (_errorCode(probeError) !== "ENOENT") throw probeError + } } try { @@ -657,6 +682,23 @@ export async function safeWriteText(filePath: string, content: string, options?: } } + // -- Step 5b (win32): restoration disabled for this process ------------------ + // Nothing was captured, so nothing could be restored: the published file answers to + // the descriptor it was created with, not the one the file it replaced had. Reported + // as the same post-commit error as a failed restore, because from the caller's side + // it is the same fact - the content is committed and the access rights changed - and + // it keeps the promise that no caller can observe success for a publish whose DACL + // was not preserved. It also has to run before step 6: with no dump in play, the + // retained backup copy is the only artifact still carrying the original descriptor + // (CopyFileW hands the destination the source's security attributes). + if (daclPreservationSkipped && daclRestoreError === null) { + daclRestoreError = new DaclRestoreError( + targetPath, + null, + "DACL restoration is disabled on this host, so the access rights of the file it replaced were not preserved", + ) + } + // -- Step 6 (backup:true): delete backup only on a fully successful publish -- // Kept when durability is unconfirmed (the rename may be lost on power loss) AND when // the DACL restore failed: that backup is the only artifact still carrying the target's From a53cbc119c61bcbbded42d1e4692ad1c98aed7da Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 21:08:43 +0800 Subject: [PATCH 45/60] fix(file-safety): name the staging directory per write Chain-level row registered on Org#1991 (comment 6108998842). The staging directory carried one shared, predictable name, .file-safety-staging. mkdirSync with recursive:true accepts an existing entry of the requested name without creating anything, and the permission repair uses chmodSync, which follows a link. A repository that ships that name as a symlink to a directory the user owns therefore has that outside directory set to 0o700 by the first confined write into the containing directory, and the staged temp file is created in it before the commit rename; a committed plain directory at that name is repaired and released by a write that never created it. The chain's single shape is a per-write unique staging-directory name, which removes the hazard by construction: the entry a write creates, chmods and writes into is one it generated. Re-derived for this branch's call shape - the guard, the abandon-on-reject cleanup and the ownership check stay, because the entry at a generated name still has to be the one this call made. The generated name starts with the shared name, so a substring probe reports a match either way: the new tests compare full names and pin the generated shape end to end. Negative control - reverting the name to the shared fixed name reddens exactly the three new tests and nothing else. --- .../__tests__/safeWriteText.spec.ts | 149 +++++++++++++++++- src/services/file-safety/safeWriteText.ts | 21 ++- 2 files changed, 156 insertions(+), 14 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index d561a16797..6fe7cf3491 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -82,10 +82,49 @@ function _resolvedTarget(filePath: string): string { function _dirPath(filePath: string): string { return path.dirname(_resolvedTarget(filePath)) } -function _stagingDir(dir: string): string { +/** + * The one shared fixed name this layer used to stage under. A workspace can still ship an entry + * with that name, and the per-write name this layer generates now STARTS with it, so a substring + * probe cannot tell the two apart - every comparison below is against the full name. + */ +function _sharedStagingDir(dir: string): string { return path.join(dir, ".file-safety-staging") } +/** + * Exact shape of a per-write staging name, anchored at both ends: the separator, the timestamp and + * the random suffix all have to be there. Matching the prefix alone would also accept the shared + * name a workspace shipped. + */ +function _isPerWriteStagingName(name: string): boolean { + return /^\.file-safety-staging_\d+_[a-z0-9]+$/.test(name) +} + +/** ENOENT stand-in, as the fs mocks report it for a path that does not exist yet. */ +function _enoent(): Error { + return Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) +} + +/** + * lstat stand-in that answers per path: the shared name reports whatever the workspace shipped at + * it, and every other path behaves like a fresh entry - absent until this call creates it, then + * a real directory owned by this process. + */ +function _lstatPerPath(shared: string, sharedStats: fsSync.Stats): (p: unknown) => fsSync.Stats { + const seen = new Set() + return (p: unknown) => { + const name = String(p) + if (name === shared) { + return sharedStats + } + if (!seen.has(name)) { + seen.add(name) + throw _enoent() + } + return _dirStats() + } +} + // Stats stand-in for the staging directory: a real directory owned by this // process, which is what _stagingDir requires before it will stage there. function _dirStats(): fsSync.Stats { @@ -175,6 +214,97 @@ describe("safeWriteText", () => { // no unlink of temp (it's now the committed file; DACL skipped via platform:linux) expect(fs.unlink).not.toHaveBeenCalled() }) + + // ── the staging directory's NAME ────────────────────────────────────────── + // One shared fixed name made the staging directory a path the workspace controls: + // mkdirSync with recursive:true accepts an existing entry of the requested name + // without creating anything, and the chmod below follows a link. The chain's shape + // is a name generated per write, so the entry a write creates, chmods and writes + // into is one it made. Every assertion here compares FULL names: the generated name + // starts with the shared name, so a substring probe cannot tell them apart. + + it("gives each self-staged write its own staging directory, of the generated shape", async () => { + const targetA = "/tmp/test-dir/target-a.txt" + const targetB = "/tmp/test-dir/target-b.txt" + vi.mocked(fs.realpath).mockImplementation(async (p) => String(p)) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetA, "a", { platform: "linux" }) + await safeWriteText(targetB, "b", { platform: "linux" }) + + const created = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0])) + expect(created).toHaveLength(2) + expect(created[0]).not.toBe(created[1]) + // Uniqueness has to come from the name itself, so the shape is pinned end to end. + for (const staging of created) { + expect(_isPerWriteStagingName(path.basename(staging))).toBe(true) + } + // Each write's permission repair names the directory it created, ... + expect(vi.mocked(fsSync.chmodSync).mock.calls.map((c) => String(c[0]))).toEqual(created) + // ...each staged file is opened inside its own directory, ... + const staged = vi + .mocked(fsSync.openSync) + .mock.calls.filter((c) => c[1] === "w") + .map((c) => String(c[0])) + expect(staged).toHaveLength(2) + expect(staged.map((p) => path.dirname(p))).toEqual(created) + // ...and each write releases its own directory, never the other one's. + expect(vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0]))).toEqual(created) + }) + + it("a symlink at the shared staging name cannot redirect the permission repair or the staged file", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const shared = _sharedStagingDir(path.dirname(targetPath)) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The workspace ships .file-safety-staging as a symlink to a directory the user owns + // outside the target directory. Only that exact name answers as a link. + vi.mocked(fsSync.lstatSync).mockImplementation(_lstatPerPath(shared, _linkStats())) + + // The link is not this write's directory, so the write does not stop at it. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).resolves.toBeUndefined() + + // The permission repair never reaches the shared name: every chmod names a directory + // of the generated shape, which is one this call created. + const repaired = vi.mocked(fsSync.chmodSync).mock.calls.map((c) => String(c[0])) + expect(repaired.length).toBeGreaterThan(0) + for (const staging of repaired) { + expect(staging).not.toBe(shared) + expect(_isPerWriteStagingName(path.basename(staging))).toBe(true) + } + // The staged file is created inside that directory and committed from it, so nothing + // lands in the outside directory the link points at. + const staged = String(vi.mocked(fsSync.openSync).mock.calls[0]?.[0]) + expect(path.dirname(staged)).toBe(repaired[0]) + expect(fs.rename).toHaveBeenCalledWith(staged, targetPath) + // And the release removes this write's directory, never the planted link. + expect(vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0]))).toEqual([repaired[0]]) + expect(fsSync.rmdirSync).not.toHaveBeenCalled() + }) + + it("a plain directory at the shared staging name is never chmod-ed or written into", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const shared = _sharedStagingDir(path.dirname(targetPath)) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // A committed plain directory owned by this process: nothing here rejects it on + // inspection, so only the generated name keeps this write out of it. + vi.mocked(fsSync.lstatSync).mockImplementation(_lstatPerPath(shared, _dirStats())) + + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).resolves.toBeUndefined() + + // The repair never touches a directory this write did not create ... + for (const call of vi.mocked(fsSync.chmodSync).mock.calls) { + expect(String(call[0])).not.toBe(shared) + } + // ...nothing is staged inside it ... + for (const call of vi.mocked(fsSync.openSync).mock.calls) { + expect(path.dirname(String(call[0]))).not.toBe(shared) + } + // ...and the release does not remove a directory the workspace shipped. + expect(fsSync.rmdirSync).not.toHaveBeenCalledWith(shared) + expect(vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0]))).not.toContain(shared) + }) }) // ── Test 2: fsync ordering ─────────────────────────────────────────────── @@ -1577,11 +1707,14 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "data", { platform: "linux" }) - // realpath is mocked to return the literal path, so the SUT never resolves it. - expect(fsSync.mkdirSync).toHaveBeenCalledWith(_stagingDir(path.dirname(targetPath)), { - recursive: true, - mode: 0o700, - }) + // The name is generated per write, so it is read back from the create rather than + // restated here: what this test pins is that the guard's ENOENT path still creates + // exactly one private staging directory, and that this write stages inside it. + const creates = vi.mocked(fsSync.mkdirSync).mock.calls + expect(creates).toHaveLength(1) + expect(creates[0]?.[1]).toEqual({ recursive: true, mode: 0o700 }) + const staged = String(vi.mocked(fsSync.openSync).mock.calls[0]?.[0]) + expect(path.dirname(staged)).toBe(String(creates[0]?.[0])) expect(fs.rename).toHaveBeenCalled() }) @@ -1610,7 +1743,9 @@ describe("safeWriteText", () => { const name = await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux" })) expect(name).toBe("UnsafeStagingDirectoryError") - expect(fsSync.rmdirSync).toHaveBeenCalledWith(_stagingDir(path.dirname(targetPath))) + // The directory removed is exactly the one this call created - read back rather than + // restated, since the name is generated per write. + expect(fsSync.rmdirSync).toHaveBeenCalledWith(String(vi.mocked(fsSync.mkdirSync).mock.calls[0]?.[0])) }) it("leaves a pre-existing staging directory when the post-create re-check rejects it", async () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 500209d3a1..c162a579f2 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -114,14 +114,21 @@ function _errorCode(error: unknown): string | undefined { : undefined } -/** Create a private staging sub-directory inside *dir* so that multiple - * concurrent writes never collide on their temp names. */ +/** Create a private per-write staging sub-directory inside *dir*. The name is + * generated here, the way the temp file inside it is named, so concurrent writes + * never collide on their temp names and never release a directory another write + * is still using. */ function _stagingDir(dir: string): string { - const sd = path.join(dir, ".file-safety-staging") - // mkdirSync(recursive) follows an existing path, so a .file-safety-staging - // symlink planted by another process would stage - and then publish - outside - // the target directory. Anything that is not a real directory owned by this - // process is rejected instead of used. + // A shared fixed name is a path the workspace controls: mkdirSync with + // recursive:true accepts an existing entry of that name without creating + // anything, so a committed .file-safety-staging symlink would have the chmod + // below repair the permissions of a directory outside the workspace and put + // the staged file in it. A name this call generated cannot be pre-planted. + const sd = path.join(dir, ".file-safety-staging_" + Date.now() + "_" + Math.random().toString(36).substring(2)) + // The name is this call's, the entry at it still has to be checked: a colliding + // or swapped-in path would stage - and then publish - outside the target + // directory. Anything that is not a real directory owned by this process is + // rejected instead of used. let preExisted = false try { const existing = fsSync.lstatSync(sd) From 63a20bf9c6e665be7c4da7e781496622ca7a5a22 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:48:05 +0800 Subject: [PATCH 46/60] fix(file-safety): refuse publishes whose target escapes the authorized root The apply-diff save path approved the lexical tool path and then let safeWriteText publish wherever the path resolved: a dangling in-workspace link approved.txt -> /tmp/outside/new/secret.txt passed the in-workspace allowlist and auto-approval, and the primitive then CREATED the outside parent (mkdir of the referent directory) and renamed the content there. The bytes landed outside the workspace the approval displayed. safeWriteText now takes an optional authorizedRoot. When set, a requested path that resolves lexically inside the root must also publish inside it: the retained target (resolvePublishTarget's result, before any mkdir) is checked against the canonicalized root and the write is refused with nothing created or written when it escaped. Requested paths that are themselves outside the root stay unconstrained - the tool layer's own approval flow governs outside-workspace writes - and a root that cannot be inspected fails closed (non-ENOENT realpath errors propagate). DiffViewProvider.saveDirectly passes the task cwd as the root, so the guarded save path can no longer spend an inside-path approval on an outside target. Tests: dangling-link and existing-symlink escapes refused before mkdir and rename, inside referents published, outside requested paths and the no-root case unchanged, lexical-root fallback for a not-yet-created root, EACCES on root inspection propagated. Negative controls: removing the refusal reddens exactly the three escape tests, dropping the inside-requested condition reddens exactly the outside-requested test, removing the ENOENT fallback reddens exactly the fallback test, and dropping the saveDirectly wiring reddens exactly the four wiring assertions. 95/96 unit (1 pre-existing skip), 75/75 diff-view; the one integration failure is the pre-existing win32 icacls/restore host limitation, identical on the clean tree. --- src/integrations/editor/DiffViewProvider.ts | 2 +- .../editor/__tests__/DiffViewProvider.spec.ts | 8 +- .../__tests__/safeWriteText.spec.ts | 119 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 57 +++++++++ 4 files changed, 181 insertions(+), 5 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index be0d1aafb5..5b8d3dbee3 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1172,7 +1172,7 @@ export class DiffViewProvider { : undefined if (code !== "ENOENT") throw error } - await safeWriteText(absolutePath, content) + await safeWriteText(absolutePath, content, { authorizedRoot: this.cwd }) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 97a6e83224..8aeed69afe 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -810,7 +810,7 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200) - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { authorizedRoot: mockCwd }) // Call counts alone would still pass if the publish ran but the caller reported // nothing about the content it wrote. expect(result.finalContent).toBe("new content") @@ -880,7 +880,7 @@ describe("DiffViewProvider", () => { // Verify file was written via safeWriteText const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { authorizedRoot: mockCwd }) // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -903,7 +903,7 @@ describe("DiffViewProvider", () => { // Verify file was written via safeWriteText const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { authorizedRoot: mockCwd }) // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() @@ -918,7 +918,7 @@ describe("DiffViewProvider", () => { // Verify file was written via safeWriteText const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", { authorizedRoot: mockCwd }) // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 6fe7cf3491..74eb8cdd38 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1376,6 +1376,125 @@ describe("safeWriteText", () => { }) }) + // ── authorized root enforcement (dangling-link escape regression) ────── + + describe("authorized root", () => { + const aurRoot = () => path.resolve("/tmp/aur-root") + const aurOutside = () => path.resolve("/tmp/aur-outside") + const enoent = () => Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + const linkStats = () => { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats & { + isSymbolicLink?: () => boolean + } + s.isSymbolicLink = () => true + return s + } + + it("refuses a dangling link inside the root whose referent is outside, creating nothing", async () => { + const root = aurRoot() + const linkPath = path.join(root, "approved.txt") + const referent = path.join(aurOutside(), "new", "secret.txt") + vi.mocked(fs.realpath).mockImplementation(async (p) => { + if (String(p) === root) return root + throw enoent() + }) + vi.mocked(fs.lstat).mockResolvedValueOnce(linkStats()) + vi.mocked(fs.readlink).mockResolvedValueOnce(referent) + + await expect(safeWriteText(linkPath, "data", { platform: "linux", authorizedRoot: root })).rejects.toThrow( + /authorized root/, + ) + + // Nothing outside may be created or written: the refusal precedes mkdir. + expect(fs.mkdir).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("refuses an existing symlink whose referent is outside the root", async () => { + const root = aurRoot() + const linkPath = path.join(root, "link.txt") + const referent = path.join(aurOutside(), "secret.txt") + vi.mocked(fs.realpath).mockImplementation(async (p) => { + if (String(p) === root) return root + return referent + }) + + await expect(safeWriteText(linkPath, "data", { platform: "linux", authorizedRoot: root })).rejects.toThrow( + /authorized root/, + ) + expect(fs.mkdir).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("publishes when the referent stays inside the root", async () => { + const root = aurRoot() + const linkPath = path.join(root, "link.txt") + const referent = path.join(root, "sub", "real.txt") + vi.mocked(fs.realpath).mockImplementation(async (p) => { + if (String(p) === root) return root + return referent + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(linkPath, "data", { platform: "linux", authorizedRoot: root }) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), referent) + }) + + it("leaves a requested path that is itself outside the root unconstrained", async () => { + const root = aurRoot() + const outside = path.join(aurOutside(), "x.txt") + vi.mocked(fs.realpath).mockImplementation(async (p) => { + if (String(p) === root) return root + return String(p) + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(outside, "data", { platform: "linux", authorizedRoot: root }) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), outside) + }) + + it("keeps the old behavior for an outside referent when no root is declared", async () => { + const linkPath = path.resolve("/tmp/aur-root/link.txt") + const referent = path.join(aurOutside(), "secret.txt") + vi.mocked(fs.realpath).mockResolvedValue(referent) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(linkPath, "data", { platform: "linux" }) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), referent) + }) + + it("falls back to the lexical root when the authorized root does not exist yet", async () => { + const root = aurRoot() + const linkPath = path.join(root, "approved.txt") + const referent = path.join(aurOutside(), "new", "secret.txt") + vi.mocked(fs.realpath).mockRejectedValue(enoent()) + vi.mocked(fs.lstat).mockResolvedValueOnce(linkStats()) + vi.mocked(fs.readlink).mockResolvedValueOnce(referent) + + await expect(safeWriteText(linkPath, "data", { platform: "linux", authorizedRoot: root })).rejects.toThrow( + /authorized root/, + ) + expect(fs.mkdir).not.toHaveBeenCalled() + }) + + it("propagates a non-ENOENT inspection failure of the authorized root", async () => { + const root = aurRoot() + const target = path.join(root, "target.txt") + vi.mocked(fs.realpath).mockImplementation(async (p) => { + if (String(p) === root) throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + return target + }) + + await expect(safeWriteText(target, "data", { platform: "linux", authorizedRoot: root })).rejects.toThrow( + "EACCES", + ) + expect(fs.rename).not.toHaveBeenCalled() + }) + }) + // ── Test 8: review fixes (permissions, partial writes, resolution, durability) ── describe("review fixes", () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index c162a579f2..54f80cc9ad 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -38,6 +38,16 @@ export interface SafeWriteTextOptions { * already written data to a temp file via a custom stream. */ tempPath?: string + + /** + * Authorized publish root. When set, a requested path that resolves lexically + * inside this root must also publish inside it: a symlink or dangling link whose + * referent escapes the root is rejected before anything is created or written, + * so an approval that covered the inside path can never be spent on an outside + * target. Requested paths that are themselves outside the root are not + * constrained here - the caller's own approval flow governs those. + */ + authorizedRoot?: string } // -- helpers --------------------------------------------------------------- @@ -366,11 +376,58 @@ export async function resolvePublishTarget( } } +/** Lexical containment: true when target sits at or under root. */ +function _isWithinRoot(root: string, target: string): boolean { + const rel = path.relative(root, target) + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) +} + +/** + * Refuse a publish whose retained target escaped the caller's authorized root + * while the requested path sat inside it: the tool layer approved the inside + * path, so an escape here (a symlink or dangling link pointing outside the + * workspace) would land bytes the approval never covered outside the root, and + * the parent-directory mkdir below would happily create the outside parent + * first. A requested path that is itself outside the root is left alone - the + * caller's own approval flow covers outside-root writes, and this primitive has + * no opinion about a root it was not asked to enforce. + */ +async function _assertPublishWithinAuthorizedRoot( + authorizedRoot: string, + requestedPath: string, + targetPath: string, +): Promise { + let root: string + try { + root = await fs.realpath(authorizedRoot) + } catch (error: unknown) { + // ENOENT falls back to the lexical root: the root may be created by this + // very write's mkdir below. Any other inspection error propagates rather + // than silently skipping the boundary. + if (_errorCode(error) !== "ENOENT") { + throw error + } + root = path.resolve(authorizedRoot) + } + if (_isWithinRoot(root, requestedPath) && !_isWithinRoot(root, targetPath)) { + throw new Error( + `safeWriteText: refusing to publish ${targetPath}: the requested path ${requestedPath} is inside the authorized root ${root} but the resolved publish target is not. Nothing was written.`, + ) + } +} + export async function safeWriteText(filePath: string, content: string, options?: SafeWriteTextOptions): Promise { const absoluteFilePath = path.resolve(filePath) // Resolve the symlink referent (see resolvePublishTarget). const targetPath = await resolvePublishTarget(absoluteFilePath) + + // Enforce the caller's authorized root before anything is created or written + // (the mkdir below can otherwise materialize an outside parent first). + if (options?.authorizedRoot !== undefined) { + await _assertPublishWithinAuthorizedRoot(options.authorizedRoot, absoluteFilePath, targetPath) + } + const dirPath = path.dirname(targetPath) // Ensure parent directory exists (mirrors safeWriteJson behaviour). From 5f75502867f68a9c8fc16d9664a08e0c670e63c1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:55:50 +0800 Subject: [PATCH 47/60] fix(file-safety): close two authorized-root classification gaps found in review - _isWithinRoot treated any leading ".." as an escape, so a sibling entry legitimately named "..config" was misclassified as outside the root: a write whose target carried such a name was refused even though it sits inside the root. Only a full ".." segment escapes. - The inside-request test compared the lexical requested path only against the canonicalized root. A workspace opened through a symlink (cwd /tmp/ws resolving to /private/tmp/ws) makes every inside request look outside, skipping the guard exactly where it matters: a dangling link under the symlinked cwd could still publish to /etc/x. The test now matches both the canonical and the lexical root for the request and the target, so an inside request is inside under either and only a target outside both is refused. Tests: an in-root "..config" target publishes through an inside link; a symlinked workspace still refuses the dangling-link escape. Negative controls: reverting either fix reddens exactly its test. 97/98 (1 pre-existing skip). --- .../__tests__/safeWriteText.spec.ts | 39 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 15 ++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 74eb8cdd38..3a76c5f4af 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -1480,6 +1480,45 @@ describe("safeWriteText", () => { expect(fs.mkdir).not.toHaveBeenCalled() }) + it("accepts an in-root target whose own name starts with two dots", async () => { + const root = aurRoot() + const linkPath = path.join(root, "link.txt") + const target = path.join(root, "..config") + vi.mocked(fs.realpath).mockImplementation(async (p) => { + if (String(p) === root) return root + return target + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(linkPath, "data", { platform: "linux", authorizedRoot: root }) + + // A sibling entry may be named "..config": a bare ".." prefix is not an + // escape, so the write must proceed rather than be refused. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), target) + }) + + it("enforces the root when the workspace itself is reached through a symlink", async () => { + // The lexical cwd (/tmp/ws below) resolves to a different canonical root + // (/private/tmp/ws): matching only the canonical root would classify + // every inside request as outside and skip the guard entirely. + const lexicalRoot = path.resolve("/tmp/ws") + const canonicalRoot = path.resolve("/private/tmp/ws") + const linkPath = path.join(lexicalRoot, "approved.txt") + const referent = path.resolve("/etc/x") + vi.mocked(fs.realpath).mockImplementation(async (p) => { + const s = String(p) + if (s === lexicalRoot) return canonicalRoot + throw enoent() + }) + vi.mocked(fs.lstat).mockResolvedValueOnce(linkStats()) + vi.mocked(fs.readlink).mockResolvedValueOnce(referent) + + await expect( + safeWriteText(linkPath, "data", { platform: "linux", authorizedRoot: lexicalRoot }), + ).rejects.toThrow(/authorized root/) + expect(fs.mkdir).not.toHaveBeenCalled() + }) + it("propagates a non-ENOENT inspection failure of the authorized root", async () => { const root = aurRoot() const target = path.join(root, "target.txt") diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 54f80cc9ad..81e65fba09 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -379,7 +379,9 @@ export async function resolvePublishTarget( /** Lexical containment: true when target sits at or under root. */ function _isWithinRoot(root: string, target: string): boolean { const rel = path.relative(root, target) - return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) + // A leading ".." is an escape only as a full segment: a sibling entry + // may legitimately be named "..config", and path.relative reports it bare. + return rel === "" || (rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel)) } /** @@ -409,7 +411,16 @@ async function _assertPublishWithinAuthorizedRoot( } root = path.resolve(authorizedRoot) } - if (_isWithinRoot(root, requestedPath) && !_isWithinRoot(root, targetPath)) { + // A workspace opened through a symlink (e.g. /tmp/ws -> /private/tmp/ws) + // makes every inside path look outside the canonicalized root, which + // would skip the check exactly where it matters. Match both the canonical + // and the lexical root: an inside request is one that sits under either, + // and an inside target is one that sits under either. + const lexicalRoot = path.resolve(authorizedRoot) + const roots = lexicalRoot === root ? [root] : [root, lexicalRoot] + const requestedInside = roots.some((candidate) => _isWithinRoot(candidate, requestedPath)) + const targetInside = roots.some((candidate) => _isWithinRoot(candidate, targetPath)) + if (requestedInside && !targetInside) { throw new Error( `safeWriteText: refusing to publish ${targetPath}: the requested path ${requestedPath} is inside the authorized root ${root} but the resolved publish target is not. Nothing was written.`, ) From f02a358d497bd320aa1aba292b9de9e93a6745b0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:17:38 +0800 Subject: [PATCH 48/60] style(file-safety): drop SafeWriteTextOptions type import unused after the main refresh (lint gate) --- src/services/file-safety/__tests__/safeWriteText.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 3a76c5f4af..9d1c99c343 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -4,7 +4,7 @@ import { execFile } from "child_process" import type { ChildProcess } from "child_process" import * as path from "path" -import { DaclRestoreError, resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" +import { DaclRestoreError, resolvePublishTarget, safeWriteText } from "../safeWriteText" // The two failure classes are module-private (knip ignores __tests__, so a // test-only export would be reported as unused), so tests match them by name. From 5bdedd2bbec42c87e075873de13a1c9629ff7523 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:00:32 +0800 Subject: [PATCH 49/60] fix(file-safety): close three review-found gaps in the authorized-root boundary and staging - authorizedRoot bypass (Major): a NEW file under a symlinked parent directory escaped the root unchecked - the retained target of a not-yet-existing path is lexical, so root/sublink/new.txt looked inside while the bytes landed at /outside/new.txt through the committed parent symlink. The inside-target test now canonicalizes the target's nearest existing ancestor and re-appends the missing tail, so the check sees where the bytes land, not where the name sits. Regression test: dangling write under a parent link out of the root is refused before mkdir. Negative control: reverting to the lexical target reddens exactly that test. - staging recovery leak (Minor): the ENOENT reopen recovery called _stagingDir() again, which generates a FRESH random name - the recreated directory did not contain tempPath, so the reopen failed again on a real filesystem and the new directory was orphaned. The recovery now recreates the same stagingDir path and verifies it is a directory before reopening. The recovery test now pins that the recreated path equals the temp file's parent; reverting the fix reddens exactly that test. - suite isolation (Minor): the two top-level suites without a beforeEach inherited mock defaults from the main suite's beforeEach, so a filtered run (-t) crashed with TypeError. The defaults moved to a shared _restoreFsDefaults() called by every suite; both filtered runs now pass standalone and fail without the beforeEach. --- .../__tests__/safeWriteText.spec.ts | 79 ++++++++++++++----- src/services/file-safety/safeWriteText.ts | 47 +++++++++-- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 9d1c99c343..4bd3de490a 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -155,26 +155,32 @@ function _stats(mode: number, gid?: number): fsSync.Stats { // ── Test 1: staging file created then cleaned after success ──────────────── +/** Reset the fs mocks and restore the defaults every suite in this + * file relies on. Shared with the top-level suites below so a filtered run + * (`vitest -t`) of any single suite starts from the same baseline. + */ +function _restoreFsDefaults(): 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) + // Link lookups default to 'not a link' so an ENOENT realpath falls back to the path. + vi.mocked(fs.lstat).mockResolvedValue(null as unknown as fsSync.Stats) + // Existing-target default: a regular 0o644 file. + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + // Staging path default: a real directory owned by this process. + vi.mocked(fsSync.lstatSync).mockReturnValue(_dirStats()) + // 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("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) - // Link lookups default to 'not a link' so an ENOENT realpath falls back to the path. - vi.mocked(fs.lstat).mockResolvedValue(null as unknown as fsSync.Stats) - // Existing-target default: a regular 0o644 file. - vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) - // Staging path default: a real directory owned by this process. - vi.mocked(fsSync.lstatSync).mockReturnValue(_dirStats()) - // 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, - ) + _restoreFsDefaults() }) describe("staging and cleanup", () => { @@ -1519,6 +1525,26 @@ describe("safeWriteText", () => { expect(fs.mkdir).not.toHaveBeenCalled() }) + it("refuses a new file whose parent directory is a symlink out of the root", async () => { + // The final component does not exist yet, so the retained target is + // lexical (root/sublink/new.txt). The parent link points outside the + // root, so the bytes land outside even though the name sits inside: + // a purely lexical inside test would let this through. + const root = aurRoot() + const requested = path.join(root, "sublink", "new.txt") + vi.mocked(fs.realpath).mockImplementation(async (p) => { + const s = String(p) + if (s === root) return root + if (s === path.join(root, "sublink")) return path.resolve("/outside") + throw enoent() + }) + + await expect(safeWriteText(requested, "data", { platform: "linux", authorizedRoot: root })).rejects.toThrow( + /authorized root/, + ) + expect(fs.mkdir).not.toHaveBeenCalled() + }) + it("propagates a non-ENOENT inspection failure of the authorized root", async () => { const root = aurRoot() const target = path.join(root, "target.txt") @@ -1782,6 +1808,15 @@ describe("safeWriteText", () => { // Once for the original staging call, once for the recovery. expect(fsSync.mkdirSync).toHaveBeenCalledTimes(2) + // The recovery must recreate the directory that holds tempPath: a fresh + // random name would leave the reopened file parentless on a real + // filesystem and orphan the directory it did create. + const mkdirCalls = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0])) + expect(mkdirCalls[1]).toBe(mkdirCalls[0]) + // The first open is the failed temp open; its parent must be the + // directory the recovery recreated. + const opened = String(vi.mocked(fsSync.openSync).mock.calls[0]?.[0]) + expect(path.dirname(opened)).toBe(mkdirCalls[0]) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) @@ -2197,6 +2232,10 @@ describe("partial backup after a failed copy (backup:true)", () => { }) describe("staging directory release", () => { + beforeEach(() => { + _restoreFsDefaults() + }) + it("retries a transient release failure once and then surfaces it", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.mocked(fs.realpath).mockResolvedValue("/tmp/test-dir/target.txt") @@ -2230,6 +2269,10 @@ describe("staging directory release", () => { }) describe("post-create staging probe failure", () => { + beforeEach(() => { + _restoreFsDefaults() + }) + it("removes a staging directory it created when the post-create lstat fails", async () => { const ioError = Object.assign(new Error("EIO"), { code: "EIO" }) vi.mocked(fs.realpath).mockResolvedValue("/tmp/test-dir/target.txt") diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 81e65fba09..1d65ccdf8e 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -384,6 +384,32 @@ function _isWithinRoot(root: string, target: string): boolean { return rel === "" || (rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel)) } +/** + * Canonicalize a path that may not exist yet: resolve the nearest existing + * ancestor and re-append the missing tail. A dangling final component under + * a symlinked parent directory would otherwise look inside the root + * lexically while the write lands wherever the parent link points. + */ +async function _realpathNearestExisting(targetPath: string): Promise { + const missing: string[] = [] + let current = targetPath + for (;;) { + try { + const real = await fs.realpath(current) + return missing.length === 0 ? real : path.join(real, ...missing.reverse()) + } catch (error: unknown) { + if (_errorCode(error) !== "ENOENT") { + throw error + } + const parent = path.dirname(current) + if (parent === current) { + return targetPath + } + missing.push(path.basename(current)) + current = parent + } + } +} /** * Refuse a publish whose retained target escaped the caller's authorized root * while the requested path sat inside it: the tool layer approved the inside @@ -419,7 +445,11 @@ async function _assertPublishWithinAuthorizedRoot( const lexicalRoot = path.resolve(authorizedRoot) const roots = lexicalRoot === root ? [root] : [root, lexicalRoot] const requestedInside = roots.some((candidate) => _isWithinRoot(candidate, requestedPath)) - const targetInside = roots.some((candidate) => _isWithinRoot(candidate, targetPath)) + // The retained target may still sit under a symlinked parent directory: + // resolve the nearest existing ancestor so the inside test sees where the + // bytes actually land, not just where the name lexically sits. + const effectiveTarget = await _realpathNearestExisting(targetPath) + const targetInside = roots.some((candidate) => _isWithinRoot(candidate, effectiveTarget)) if (requestedInside && !targetInside) { throw new Error( `safeWriteText: refusing to publish ${targetPath}: the requested path ${requestedPath} is inside the authorized root ${root} but the resolved publish target is not. Nothing was written.`, @@ -528,12 +558,17 @@ export async function safeWriteText(filePath: string, content: string, options?: try { fd = fsSync.openSync(tempPath, "w", targetMode) } catch (error: unknown) { - // Every write to one directory shares the staging dir, and a write that finishes - // removes it when empty. A concurrent writer can therefore remove it between - // _stagingDir() and this open. Re-create it and try once; any other errno, or - // a second ENOENT, is a real failure. + // The staging directory belongs to this write alone, but its release is + // best-effort and retried, so a lingering release can still race this open. + // Recreate the same path - a fresh random name would not contain tempPath, + // the reopen would fail again on a real filesystem, and the new directory + // would be orphaned - and verify it is a real directory before reopening. + // Any other errno, or a second ENOENT, is a real failure. if (_errorCode(error) !== "ENOENT" || stagingDir === null) throw error - _stagingDir(dirPath) + fsSync.mkdirSync(stagingDir, { recursive: true, mode: 0o700 }) + if (!fsSync.lstatSync(stagingDir).isDirectory()) { + throw new UnsafeStagingDirectoryError(stagingDir, "it exists and is not a directory") + } fd = fsSync.openSync(tempPath, "w", targetMode) } // The creation mode handed to openSync is masked by the process umask: an existing From 78ff00a342a5cc42818bea0a1c05217a8a77336d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:04:01 +0800 Subject: [PATCH 50/60] fix(editor): check the authorized root before creating directories in saveDirectly saveDirectly ran createDirectoriesForFile before safeWriteText's authorizedRoot check, and createDirectoriesForFile follows symlinks: an approved inside path whose intermediate link points outside the workspace mutated the outside filesystem before the publish was rejected. Export the canonical ancestor check from safeWriteText and run it first, so the allowlist decision has no filesystem side effects (Security Boundaries row). --- src/integrations/editor/DiffViewProvider.ts | 11 +++++++++- .../editor/__tests__/DiffViewProvider.spec.ts | 21 +++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 4 ++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 5b8d3dbee3..af55b7c5c2 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -19,7 +19,7 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" -import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { assertPublishWithinAuthorizedRoot, safeWriteText } from "../../services/file-safety/safeWriteText" import { DecorationController } from "./DecorationController" @@ -1157,6 +1157,15 @@ export class DiffViewProvider { // Get diagnostics before editing the file this.preDiagnostics = vscode.languages.getDiagnostics() + // Reject an escape through an intermediate symlink before anything is + // created on disk: createDirectoriesForFile follows symlinks, so an + // approved inside path whose intermediate link points outside the + // workspace would otherwise create the outside directory before + // safeWriteText's authorizedRoot check rejected the publish. The same + // canonical check runs inside safeWriteText; running it here first keeps + // the allowlist decision free of filesystem side effects. + await assertPublishWithinAuthorizedRoot(this.cwd, absolutePath, absolutePath) + // Write the content directly to the file await createDirectoriesForFile(absolutePath) // safeWriteText publishes with rename, which only needs write permission on the diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 8aeed69afe..1aa1fd9cc8 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -31,6 +31,7 @@ vi.mock("../../../services/file-safety/safeWriteText", async (importOriginal) => return { ...actual, safeWriteText: vi.fn().mockResolvedValue(undefined), + assertPublishWithinAuthorizedRoot: vi.fn().mockResolvedValue(undefined), } }) @@ -786,6 +787,26 @@ describe("DiffViewProvider", () => { }) describe("saveDirectly method", () => { + it("rejects an escaped target before creating directories for it", async () => { + const { assertPublishWithinAuthorizedRoot, safeWriteText } = + await import("../../../services/file-safety/safeWriteText") + const { createDirectoriesForFile } = await import("../../../utils/fs") + vi.mocked(createDirectoriesForFile).mockClear() + vi.mocked(safeWriteText).mockClear() + const escape = new Error("safeWriteText: refusing to publish outside the authorized root") + vi.mocked(assertPublishWithinAuthorizedRoot).mockRejectedValueOnce(escape) + + await expect(diffViewProvider.saveDirectly("linkdir/new/secret.txt", "x", false, false, 0)).rejects.toThrow( + "refusing to publish", + ) + + // The allow-list decision must precede the filesystem mutation: + // createDirectoriesForFile follows symlinks, so running it first would + // already have created the outside directory the check exists to protect. + expect(createDirectoriesForFile).not.toHaveBeenCalled() + expect(safeWriteText).not.toHaveBeenCalled() + vi.mocked(assertPublishWithinAuthorizedRoot).mockResolvedValue(undefined) + }) it("rejects a save over a read-only target instead of renaming onto it", async () => { const fs = await import("fs/promises") const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 1d65ccdf8e..e097b87d93 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -420,7 +420,7 @@ async function _realpathNearestExisting(targetPath: string): Promise { * caller's own approval flow covers outside-root writes, and this primitive has * no opinion about a root it was not asked to enforce. */ -async function _assertPublishWithinAuthorizedRoot( +export async function assertPublishWithinAuthorizedRoot( authorizedRoot: string, requestedPath: string, targetPath: string, @@ -466,7 +466,7 @@ export async function safeWriteText(filePath: string, content: string, options?: // Enforce the caller's authorized root before anything is created or written // (the mkdir below can otherwise materialize an outside parent first). if (options?.authorizedRoot !== undefined) { - await _assertPublishWithinAuthorizedRoot(options.authorizedRoot, absoluteFilePath, targetPath) + await assertPublishWithinAuthorizedRoot(options.authorizedRoot, absoluteFilePath, targetPath) } const dirPath = path.dirname(targetPath) From d7f4ca10df9f7276946bee0e9a0cc7ab5f1413f0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:14:00 +0800 Subject: [PATCH 51/60] fix(file-safety): treat post-commit DACL loss as a host limitation and lock the referent Review follow-ups: (1) a host whose icacls /restore exits 1300 now publishes successfully with one process warning instead of throwing DaclRestoreError after the rename on every overwrite; non-privilege restore failures still throw; (2) DiffViewProvider.saveDirectly treats PublishNotDurableError and DaclRestoreError as a completed save with a warning so the tool layer does not retry content already on disk; (3) file locks canonicalise through resolveRealPath (nearest existing ancestor, case preserved) so TaskHistoryStore and safeWriteJson serialize on the same lock through a symlinked ancestor; (4) safeWriteText reuses WorkspacePathResolver.resolveRealPath via a caseFold option instead of a private copy. --- src/integrations/editor/DiffViewProvider.ts | 22 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 37 ++++-- .../__tests__/safeWriteText.spec.ts | 85 ++++---------- src/services/file-safety/safeWriteText.ts | 111 ++++++------------ src/utils/WorkspacePathResolver.ts | 19 ++- .../__tests__/WorkspacePathResolver.spec.ts | 17 +++ src/utils/__tests__/fileLock.spec.ts | 75 ++++++++++++ src/utils/fileLock.ts | 23 +++- 8 files changed, 238 insertions(+), 151 deletions(-) create mode 100644 src/utils/__tests__/fileLock.spec.ts diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index af55b7c5c2..b41ce3b960 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -19,7 +19,12 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" -import { assertPublishWithinAuthorizedRoot, safeWriteText } from "../../services/file-safety/safeWriteText" +import { + assertPublishWithinAuthorizedRoot, + DaclRestoreError, + PublishNotDurableError, + safeWriteText, +} from "../../services/file-safety/safeWriteText" import { DecorationController } from "./DecorationController" @@ -1181,7 +1186,20 @@ export class DiffViewProvider { : undefined if (code !== "ENOENT") throw error } - await safeWriteText(absolutePath, content, { authorizedRoot: this.cwd }) + try { + await safeWriteText(absolutePath, content, { authorizedRoot: this.cwd }) + } catch (error) { + // Both errors are raised after the atomic rename committed the content: they + // describe a durability or access-rights condition the tool layer cannot fix by + // writing again. Treat the save as completed so the tool's bookkeeping matches + // the disk (and content already on disk is not retried), and surface the + // condition as a warning instead. + if (error instanceof PublishNotDurableError || error instanceof DaclRestoreError) { + console.warn(`saveDirectly: content committed with a warning: ${error.message}`) + } else { + throw error + } + } // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 1aa1fd9cc8..397e92d0d4 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -867,24 +867,43 @@ describe("DiffViewProvider", () => { ) }) - it("surfaces a failed DACL restore as a rejection instead of a completed save", async () => { + it("reports a post-commit DACL restore failure as a warning instead of a failed save", async () => { const { DaclRestoreError, safeWriteText } = await import("../../../services/file-safety/safeWriteText") vi.mocked(safeWriteText).mockClear() - // The primitive reports a lost DACL after the commit rename as DaclRestoreError. - // The direct-save path must not swallow it: a caller that sees saveDirectly - // resolve would report a save whose access rights silently changed. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + // The primitive reports a lost DACL after the commit rename as DaclRestoreError: + // the content is already on disk, so re-writing cannot change the access rights. + // The tool layer must still record the write as done, or it keeps stale state and + // retries content that is already published. vi.mocked(safeWriteText).mockRejectedValueOnce(new DaclRestoreError(`${mockCwd}/test.ts`, null)) - await expect( - diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200), - ).rejects.toBeInstanceOf(DaclRestoreError) + const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200) - // The rejection is the primitive's own - the publish was attempted exactly once, - // not retried into a success the caller could observe. + expect(result.finalContent).toBe("new content") + expect(warn).toHaveBeenCalledWith(expect.stringContaining("content committed with a warning")) const publishCalls = vi.mocked(safeWriteText).mock.calls.filter(function (call) { return call[0] === `${mockCwd}/test.ts` }) expect(publishCalls).toHaveLength(1) + warn.mockRestore() + }) + + it("reports an unconfirmed-durability publish as a warning instead of a failed save", async () => { + const { PublishNotDurableError, safeWriteText } = + await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockClear() + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + // The rename landed before the durability failure was observed, so the content is + // committed: a re-write would duplicate the work and skip the save bookkeeping. + vi.mocked(safeWriteText).mockRejectedValueOnce( + new PublishNotDurableError(`${mockCwd}/test.ts`, "directory fsync failed"), + ) + + const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200) + + expect(result.finalContent).toBe("new content") + expect(warn).toHaveBeenCalledWith(expect.stringContaining("content committed with a warning")) + warn.mockRestore() }) beforeEach(() => { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 4bd3de490a..7364d2b24a 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -710,15 +710,12 @@ describe("safeWriteText", () => { expect(restoreCalls.length).toBeGreaterThan(0) }) - it("skips the DACL save and restore for the rest of the process once a restore reports the missing privilege", async () => { + it("skips the DACL save and restore after a restore reports the missing privilege and still succeeds", async () => { // The memo is process-wide, so this test runs against a fresh module instance: // otherwise the write below would disable DACL handling for every later test in // the file. vi.mock factories still apply to the re-imported module. vi.resetModules() - // The re-imported module has its own class identity, so the rejection is matched - // against the class this instance throws, not the one imported at the top. - const { safeWriteText: freshWriteText, DaclRestoreError: FreshDaclRestoreError } = - await import("../safeWriteText") + const { safeWriteText: freshWriteText } = await import("../safeWriteText") const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -733,9 +730,9 @@ describe("safeWriteText", () => { }) as unknown as typeof execFile const options = { platform: "win32", execFileRunner: runner } - // The first publish still loses its DACL, so it is reported as the typed error - - // the memo only changes what LATER writes in this process pay for. - await expect(freshWriteText(targetPath, "data", options)).rejects.toBeInstanceOf(FreshDaclRestoreError) + // A host that cannot restore DACLs cannot do better, and the content is committed: + // the publish succeeds with one warning instead of failing every overwrite. + await expect(freshWriteText(targetPath, "data", options)).resolves.toBeUndefined() // Exactly one save and one restore: the 1300 exit tells the restore it is // the missing privilege, so the transient retry must not run. expect(calls).toBe(2) @@ -743,9 +740,9 @@ describe("safeWriteText", () => { calls = 0 warn.mockClear() - // The memo buys the zero-spawn path; it does not buy a silent success. This write - // still replaced the target's descriptor, so it is still reported. - await expect(freshWriteText(targetPath, "data", options)).rejects.toBeInstanceOf(FreshDaclRestoreError) + // The memo buys the zero-spawn path; the access-rights limitation was already + // reported once for this process, so the write resolves without repeating it. + await expect(freshWriteText(targetPath, "data", options)).resolves.toBeUndefined() expect(calls).toBe(0) expect(warn).not.toHaveBeenCalled() @@ -753,15 +750,11 @@ describe("safeWriteText", () => { warn.mockRestore() }) - it("win32 DACL: a later write that ran with restoration disabled reports the lost DACL", async () => { - // The memo skips the save/restore pair to save the process spawns. It must not also - // change the answer: the commit is a rename, so a later write over an existing target - // still hands the path a file created with a different descriptor, and with the pair - // skipped nothing saved the old one and nothing can put it back. Resolving quietly - // would tell the caller the access rights are unchanged when they are not. + it("does not retain a backup when the only defect is the missing restore privilege", async () => { + // The DACL limitation is a host capability, not a data-integrity failure, so the + // publish is a success and the backup is not needed as a descriptor-recovery copy. vi.resetModules() - const { safeWriteText: freshWriteText, DaclRestoreError: FreshDaclRestoreError } = - await import("../safeWriteText") + const { safeWriteText: freshWriteText } = await import("../safeWriteText") const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -775,69 +768,39 @@ describe("safeWriteText", () => { }) as unknown as typeof execFile const options = { platform: "win32", execFileRunner: runner, backup: true } - // First write: capture ran, restore reported 1300, memo set, own loss reported. - await expect(freshWriteText(targetPath, "data", options)).rejects.toBeInstanceOf(FreshDaclRestoreError) + await expect(freshWriteText(targetPath, "data", options)).resolves.toBeUndefined() - calls = 0 - vi.mocked(fs.copyFile).mockClear() - vi.mocked(fs.unlink).mockClear() - const second = await _rejectionReport(freshWriteText(targetPath, "data", options)) - - // The condition reaches the caller as the same post-commit error a failed restore - // uses, instead of a resolved write, and the report names the condition it is - // reporting: a reader must be able to tell 'this host cannot restore' apart from - // 'the restore command failed this time'. - expect(second.name).toBe("DaclRestoreError") - expect(second.message).toContain("restoration is disabled") - // Restoration stayed disabled: the memo still buys zero process spawns. - expect(calls).toBe(0) - // The content is still committed - a landed publish whose access rights changed, - // not a refused write. + expect(calls).toBe(2) + // The content is committed and the backup is cleaned up like any successful publish. expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) - // And the backup copy, now the only artifact carrying the original descriptor, was - // really created and really retained. const copies = vi.mocked(fs.copyFile).mock.calls expect(copies).toHaveLength(1) - expect(String(copies[0]?.[0])).toBe(targetPath) const backupDestination = String(copies[0]?.[1]) expect( vi.mocked(fs.unlink).mock.calls.filter((call) => String(call[0]) === backupDestination), - ).toHaveLength(0) + ).toHaveLength(1) warn.mockRestore() }) - it("win32 DACL: restoration disabled does not reject a write to a brand-new target", async () => { - // A file that did not exist has no descriptor to lose - it takes a default one at - // creation either way. Reporting the condition there would fail ordinary saves on - // every un-elevated Windows host, so the report is scoped to replaced targets. + it("keeps a non-privilege restore failure as the typed post-commit error", async () => { + // A restore failure that is not the host capability limit could clear on a retry or + // point at a real ACL problem, so it stays an error even though the content committed. vi.resetModules() const { safeWriteText: freshWriteText, DaclRestoreError: FreshDaclRestoreError } = await import("../safeWriteText") const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) - const privilegeFailure = Object.assign(new Error("Access is denied."), { code: 1300 }) + const restoreFailure = new Error("icacls failed for an unrelated reason") const runner = vi.fn((...args: unknown[]) => { const cb = args[args.length - 1] as (err: Error | null) => void - cb(args[1] && String((args[1] as string[])[1]) === "/restore" ? privilegeFailure : null) + cb(args[1] && String((args[1] as string[])[1]) === "/restore" ? restoreFailure : null) }) as unknown as typeof execFile - const options = { platform: "win32", execFileRunner: runner } - - await expect(freshWriteText(targetPath, "data", options)).rejects.toBeInstanceOf(FreshDaclRestoreError) - - // From here the target is gone: the step 2b probe must read ENOENT as "nothing to - // preserve" and let the publish resolve. - vi.mocked(fs.access).mockImplementation(async (p) => { - if (typeof p === "string" && p.endsWith("target.txt")) { - throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) - } - return undefined - }) - await expect(freshWriteText(targetPath, "data", options)).resolves.toBeUndefined() + await expect( + freshWriteText(targetPath, "data", { platform: "win32", execFileRunner: runner }), + ).rejects.toBeInstanceOf(FreshDaclRestoreError) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) - warn.mockRestore() }) it("win32 DACL save args are [targetPath, /save, dumpPath] before backup rename", async () => { diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index e097b87d93..25d0e7dc89 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -3,6 +3,8 @@ import * as fsSync from "fs" import * as path from "path" import { execFile } from "child_process" +import { resolveRealPath } from "../../utils/WorkspacePathResolver" + /** * Options for safeWriteText atomic text publish primitive. */ @@ -102,10 +104,15 @@ export class PublishNotDurableError extends Error { } /** - * The content is committed but the saved DACL could not be put back on it, so the file at the - * target answers to different access rights than the one it replaced. Reported as an error rather - * than a warning: the caller has to know that the publish changed who can read the file. Raised - * after the commit rename, so the content is at the target and the backup copy is retained. + * The content is committed but a captured DACL could not be put back on it, so the file at the + * target answers to different access rights than the one it replaced. Raised after the commit + * rename, so the content is at the target and the backup copy is retained. + * + * A host that cannot restore DACLs at all (icacls /restore exits 1300 because the process lacks + * the backup/restore privilege) is treated as a logged host limitation instead: the content is + * committed and no retry can fix the capability, so failing every overwrite there would break + * ordinary saves without changing the outcome. Only restore failures that a retry or a caller + * could address are raised as this error. */ export class DaclRestoreError extends Error { constructor( @@ -391,24 +398,10 @@ function _isWithinRoot(root: string, target: string): boolean { * lexically while the write lands wherever the parent link points. */ async function _realpathNearestExisting(targetPath: string): Promise { - const missing: string[] = [] - let current = targetPath - for (;;) { - try { - const real = await fs.realpath(current) - return missing.length === 0 ? real : path.join(real, ...missing.reverse()) - } catch (error: unknown) { - if (_errorCode(error) !== "ENOENT") { - throw error - } - const parent = path.dirname(current) - if (parent === current) { - return targetPath - } - missing.push(path.basename(current)) - current = parent - } - } + // Same walk as WorkspacePathResolver.resolveRealPath, with case folding off: the result is + // compared against the authorized root and used for publish decisions, so the on-disk case + // must be preserved and a non-ENOENT error must fail closed (both provided by the helper). + return resolveRealPath(targetPath, { caseFold: false }) } /** * Refuse a publish whose retained target escaped the caller's authorized root @@ -525,12 +518,6 @@ export async function safeWriteText(filePath: string, content: string, options?: let daclRestoreError: DaclRestoreError | null = null let daclDumpPath: string | null = null // tracked for cleanup in finally let daclSaved = false // restore step runs only when the save succeeded - // Set when restoration is disabled for this process and this write replaces an existing - // target. The commit is a rename, and a rename does not hand the target the descriptor of - // the file it replaced, so the published file answers to inherited access rights that - // nothing saved and nothing can put back. Reported after the commit like a failed restore: - // the content is committed, so this is not a failure to roll back. - let daclPreservationSkipped = false const platform = options?.platform ?? process.platform @@ -689,22 +676,6 @@ export async function safeWriteText(filePath: string, content: string, options?: } daclDumpPath = null } - } else if (platform === "win32") { - // -- Step 2b (win32): restoration is disabled for this process ---------------- - // The memo skips the save/restore pair to save the process spawns, but the commit is - // still a rename over whatever sits at the target, and a rename does not hand the - // published file the descriptor of the file it replaced. A brand-new file takes a - // default descriptor and loses nothing, so only an existing target is a real loss - - // and that loss has to reach the caller instead of resolving quietly. One probe, no - // process spawn: the memo keeps its cost saving. - try { - await fs.access(targetPath) - daclPreservationSkipped = true - } catch (probeError: unknown) { - // Same rule as the capture probe: only ENOENT proves there is no descriptor to - // preserve. An unreadable target is not evidence that a plain success is safe. - if (_errorCode(probeError) !== "ENOENT") throw probeError - } } try { @@ -775,40 +746,32 @@ export async function safeWriteText(filePath: string, content: string, options?: attempt = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) } if (!attempt.restored) { - if (attempt.privilegeUnavailable && !daclRestorePrivilegeUnavailable) { - daclRestorePrivilegeUnavailable = true - console.warn( - "safeWriteText: this host cannot restore DACLs (icacls /restore reports a missing privilege), so later writes in this process will skip saving and restoring them.", - ) + // A host without the backup/restore privilege cannot do better: no retry + // grants a privilege mid-process, and the content is already committed. Warn + // once, memo the limitation, and let the publish succeed - failing every + // overwrite on such a host would break ordinary saves without changing the + // access rights. Non-privilege restore failures stay errors: those are the + // failures a caller or a later retry could actually address. + if (attempt.privilegeUnavailable) { + if (!daclRestorePrivilegeUnavailable) { + daclRestorePrivilegeUnavailable = true + console.warn( + "safeWriteText: this host cannot restore DACLs (icacls /restore reports a missing privilege); the published content keeps the access rights inherited from its directory, and later writes in this process skip saving and restoring them.", + ) + } + } else { + // The content is committed, but the published file answers to a different + // DACL than the one that was saved, and nothing here verified an equivalent + // restrictive ACL on the replacement. Reported as an error rather than a + // warning: a publish that changed who can read the file must not look like + // an ordinary successful save. Thrown after the rollback handler below - the + // content is committed, so this is not a failure to roll back, and the + // backup has to survive for recovery. + daclRestoreError = new DaclRestoreError(targetPath, null) } - // The content is committed, but the published file answers to a different DACL - // than the one that was saved, and nothing here verified an equivalent - // restrictive ACL on the replacement. Reported as an error rather than a - // warning: a publish that changed who can read the file must not look like an - // ordinary successful save. Thrown after the rollback handler below - the - // content is committed, so this is not a failure to roll back, and the backup - // has to survive for recovery. - daclRestoreError = new DaclRestoreError(targetPath, null) } } - // -- Step 5b (win32): restoration disabled for this process ------------------ - // Nothing was captured, so nothing could be restored: the published file answers to - // the descriptor it was created with, not the one the file it replaced had. Reported - // as the same post-commit error as a failed restore, because from the caller's side - // it is the same fact - the content is committed and the access rights changed - and - // it keeps the promise that no caller can observe success for a publish whose DACL - // was not preserved. It also has to run before step 6: with no dump in play, the - // retained backup copy is the only artifact still carrying the original descriptor - // (CopyFileW hands the destination the source's security attributes). - if (daclPreservationSkipped && daclRestoreError === null) { - daclRestoreError = new DaclRestoreError( - targetPath, - null, - "DACL restoration is disabled on this host, so the access rights of the file it replaced were not preserved", - ) - } - // -- Step 6 (backup:true): delete backup only on a fully successful publish -- // Kept when durability is unconfirmed (the rename may be lost on power loss) AND when // the DACL restore failed: that backup is the only artifact still carrying the target's diff --git a/src/utils/WorkspacePathResolver.ts b/src/utils/WorkspacePathResolver.ts index 42536991d7..10e425c8ec 100644 --- a/src/utils/WorkspacePathResolver.ts +++ b/src/utils/WorkspacePathResolver.ts @@ -37,7 +37,18 @@ function normalizeCase(p: string): string { * may itself be reached via a symlink. Callers should always compare two `resolveRealPath()` results * rather than mixing with raw `uri.fsPath` — `arePathsEqual()` does not case-fold on macOS. */ -export async function resolveRealPath(target: string): Promise { +export interface ResolveRealPathOptions { + /** + * Lowercase the result on case-insensitive filesystems (default true). Workspace + * comparisons want the folded form; callers that use the result for filesystem + * operations (locks, renames) must pass false so the on-disk case is preserved. + */ + caseFold?: boolean +} + +export async function resolveRealPath(target: string, options: ResolveRealPathOptions = {}): Promise { + const caseFold = options.caseFold ?? true + const finish = (resolved: string): string => (caseFold ? normalizeCase(resolved) : resolved) let current = path.resolve(target) const trailing: string[] = [] @@ -46,7 +57,7 @@ export async function resolveRealPath(target: string): Promise { try { const resolved = await fs.realpath(current) const joined = trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved - return normalizeCase(joined) + return finish(joined) } catch (err) { if (!isErrnoException(err, "ENOENT")) { // Non-ENOENT (e.g. EACCES, ELOOP, ENOTDIR): propagate so the caller's @@ -57,8 +68,8 @@ export async function resolveRealPath(target: string): Promise { const parent = path.dirname(current) if (parent === current) { // Reached the filesystem root without finding an existing path; fall back to the - // lexically resolved path (still case-normalized for consistent comparisons). - return normalizeCase(path.resolve(target)) + // lexically resolved path (case-normalized for comparisons unless disabled). + return finish(path.resolve(target)) } trailing.push(path.basename(current)) diff --git a/src/utils/__tests__/WorkspacePathResolver.spec.ts b/src/utils/__tests__/WorkspacePathResolver.spec.ts index f77f365609..a02fe32c81 100644 --- a/src/utils/__tests__/WorkspacePathResolver.spec.ts +++ b/src/utils/__tests__/WorkspacePathResolver.spec.ts @@ -132,3 +132,20 @@ describe("resolveRealPath", () => { } }) }) + +describe("resolveRealPath case folding option", () => { + it("preserves the on-disk case when case folding is disabled", async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-wpr-case-"))) + const mixed = path.join(root, "MixedCase") + await fs.mkdir(mixed) + try { + const preserved = await resolveRealPath(mixed, { caseFold: false }) + expect(preserved).toBe(path.join(root, "MixedCase")) + if (process.platform === "darwin" || process.platform === "win32") { + expect(await resolveRealPath(mixed)).toBe(preserved.toLowerCase()) + } + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/utils/__tests__/fileLock.spec.ts b/src/utils/__tests__/fileLock.spec.ts new file mode 100644 index 0000000000..36e3fc4a8f --- /dev/null +++ b/src/utils/__tests__/fileLock.spec.ts @@ -0,0 +1,75 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { afterEach, describe, expect, it } from "vitest" + +import { acquireFileLock, resolveFileLockPath, withFileLock } from "../fileLock" + +const temporaryDirectories: string[] = [] + +async function makeTemporaryDirectory(): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "file-lock-")) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await fs.rm(directory, { recursive: true, force: true }).catch(() => {}) + } +}) + +describe("resolveFileLockPath", () => { + it("resolves a symlinked ancestor to the same lock path as the referent", async () => { + const root = await makeTemporaryDirectory() + const realDirectory = path.join(root, "real") + const aliasDirectory = path.join(root, "alias") + await fs.mkdir(realDirectory) + // A directory junction needs no elevation on Windows; symlinks are used elsewhere. + await fs.symlink(realDirectory, aliasDirectory, process.platform === "win32" ? "junction" : "dir") + + const aliasPath = path.join(aliasDirectory, "settings.json") + const referentPath = path.join(realDirectory, "settings.json") + + await expect(resolveFileLockPath(aliasPath)).resolves.toBe(path.resolve(referentPath)) + await expect(resolveFileLockPath(referentPath)).resolves.toBe(path.resolve(referentPath)) + }) + + it("keeps an ordinary path unchanged", async () => { + const root = await makeTemporaryDirectory() + const target = path.join(root, "ordinary.json") + await expect(resolveFileLockPath(target)).resolves.toBe(path.resolve(target)) + }) +}) + +describe("acquireFileLock", () => { + it("serializes a writer that locks the referent and one that locks the alias", async () => { + const root = await makeTemporaryDirectory() + const realDirectory = path.join(root, "real") + const aliasDirectory = path.join(root, "alias") + await fs.mkdir(realDirectory) + await fs.symlink(realDirectory, aliasDirectory, process.platform === "win32" ? "junction" : "dir") + + const aliasPath = path.join(aliasDirectory, "settings.json") + const referentPath = path.join(realDirectory, "settings.json") + + let signalEntered!: () => void + let releaseOperation!: () => void + const entered = new Promise((resolve) => { + signalEntered = resolve + }) + const release = new Promise((resolve) => { + releaseOperation = resolve + }) + const holding = withFileLock(aliasPath, async () => { + signalEntered() + await release + }) + + await entered + await expect(acquireFileLock(referentPath)).rejects.toThrow() + releaseOperation() + await holding + }, 15_000) +}) diff --git a/src/utils/fileLock.ts b/src/utils/fileLock.ts index 49807ea154..d8bdbf910b 100644 --- a/src/utils/fileLock.ts +++ b/src/utils/fileLock.ts @@ -2,6 +2,8 @@ import * as path from "path" import { tracePersistenceEvent } from "../core/task-persistence/persistenceTrace" import * as lockfile from "proper-lockfile" +import { resolveRealPath } from "./WorkspacePathResolver" + /** * Shared staleness window for per-file advisory locks. This module owns the * single advisory lock protocol used by `safeWriteJson` and by callers that @@ -19,6 +21,19 @@ export interface FileLockOptions { immediate?: boolean } +/** + * Resolve the canonical path the advisory lock is keyed on. + * + * Locks must key on the referent, not the caller's alias: two writers reaching one file + * through a symlinked ancestor would otherwise serialize on different locks and lose each + * other's updates. resolveRealPath resolves the nearest existing ancestor, so a file that + * does not exist yet under a symlinked directory still locks under the real directory. Case + * folding is disabled because the result is used for filesystem operations, not comparisons. + */ +export async function resolveFileLockPath(filePath: string): Promise { + return resolveRealPath(path.resolve(filePath), { caseFold: false }) +} + /** * Acquire the advisory lock for one file path using the exact protocol * `safeWriteJson` uses, so operations that hold this lock serialize with @@ -27,7 +42,7 @@ export interface FileLockOptions { * while holding it. */ export async function acquireFileLock(filePath: string, options?: FileLockOptions): Promise<() => Promise> { - const absoluteFilePath = path.resolve(filePath) + const absoluteFilePath = await resolveFileLockPath(filePath) if (options?.waitForStaleLock) tracePersistenceEvent("json-lock-wait", { filePath: absoluteFilePath }) try { return await lockfile.lock(absoluteFilePath, { @@ -72,6 +87,12 @@ export async function withFileLock( operation: (absoluteFilePath: string) => Promise, options?: FileLockOptions, ): Promise { + // The operation is handed the caller's own resolved spelling. Canonicalising here would give the + // delete path a different string for one file whenever an 8.3 alias or a junctioned ancestor + // is in play, and every comparison it makes against names it built from its own path - the + // backup guard and the leftover-prefix match in jsonFileRecovery - is a string comparison. + // The lock key and the artefact stay on resolveFileLockPath below, which is what makes an + // alias and its referent contend; only the path the operation sees changes. const absoluteFilePath = path.resolve(filePath) const releaseLock = await acquireFileLock(absoluteFilePath, options) From 9ff5d5a87c4aacf808d634c97b52024a6e7bc942 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:15:57 +0800 Subject: [PATCH 52/60] perf(file-safety): run the fsync barriers on the libuv thread pool Review follow-up: the durability fsyncs (staged file and parent directory) now use the async fs.fsync form instead of fsyncSync, so the ~7 ms syscall no longer blocks the extension host event loop; this is the same syscall FileHandle.sync() uses internally. fsync failures still reject before the rename, and ordering assertions still prove file-fsync-before-close and directory-fsync-after-rename. Tests 98 passed. --- .../__tests__/safeWriteText.spec.ts | 28 +++++++++---------- src/services/file-safety/safeWriteText.ts | 20 ++++++++----- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 7364d2b24a..1f4e7eecf4 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -55,7 +55,7 @@ vi.mock("fs", () => ({ writeSync: vi.fn(), closeSync: vi.fn(), mkdirSync: vi.fn(), - fsyncSync: vi.fn(), + fsync: vi.fn((_fd: number, callback: (error: Error | null) => void) => callback(null)), chmodSync: vi.fn(), fchmodSync: vi.fn(), fchownSync: vi.fn(), @@ -208,8 +208,8 @@ describe("safeWriteText", () => { // 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) + // fsync (async form) was called on the fd + expect(fsSync.fsync).toHaveBeenCalledWith(1, expect.any(Function)) // file was closed expect(fsSync.closeSync).toHaveBeenCalledWith(1) @@ -323,13 +323,13 @@ describe("safeWriteText", () => { await safeWriteText(targetPath, "data", { platform: "linux" }) - // Verify call order: openSync(temp) → writeSync → fsyncSync(temp) + // Verify call order: openSync(temp) → writeSync → fsync(temp) // → closeSync(temp) → rename. On POSIX the parent directory is then - // opened and fsynced after the commit rename, so openSync/fsyncSync/ + // opened and fsynced after the commit rename, so openSync/fsync/ // 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.fsync).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 @@ -343,14 +343,14 @@ describe("safeWriteText", () => { const renameOrder = firstCallOf(vi.mocked(fs.rename)) expect(firstCallOf(vi.mocked(fsSync.openSync))).toBeLessThan(renameOrder) expect(firstCallOf(vi.mocked(fsSync.writeSync))).toBeLessThan(renameOrder) - expect(firstCallOf(vi.mocked(fsSync.fsyncSync))).toBeLessThan(renameOrder) + expect(firstCallOf(vi.mocked(fsSync.fsync))).toBeLessThan(renameOrder) expect(firstCallOf(vi.mocked(fsSync.closeSync))).toBeLessThan(renameOrder) // the staged file is fsynced before it is closed - expect(vi.mocked(fsSync.fsyncSync).mock.invocationCallOrder[0]).toBeLessThan( + expect(vi.mocked(fsSync.fsync).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(fsSync.closeSync).mock.invocationCallOrder[0], ) // the parent-directory fsync is the second fsync and lands AFTER the rename - expect(vi.mocked(fsSync.fsyncSync).mock.invocationCallOrder[1]).toBeGreaterThan(renameOrder) + expect(vi.mocked(fsSync.fsync).mock.invocationCallOrder[1]).toBeGreaterThan(renameOrder) }) }) @@ -1189,7 +1189,7 @@ describe("safeWriteText", () => { expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") // fsync was called - expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + expect(fsSync.fsync).toHaveBeenCalledWith(1, expect.any(Function)) // rename happened — realpath mock returns targetPath expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) @@ -1657,7 +1657,7 @@ describe("safeWriteText", () => { 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(fsSync.fsync).toHaveBeenCalledWith(1, expect.any(Function)) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) @@ -1673,8 +1673,8 @@ describe("safeWriteText", () => { // 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.fsync).toHaveBeenNthCalledWith(1, 1, expect.any(Function)) + expect(fsSync.fsync).toHaveBeenNthCalledWith(2, 2, expect.any(Function)) expect(fsSync.closeSync).toHaveBeenCalledWith(2) }) @@ -1988,7 +1988,7 @@ describe("safeWriteText", () => { vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) const fsyncError = new Error("EBADF: fsync failed") - vi.mocked(fsSync.fsyncSync).mockImplementationOnce(() => { + vi.mocked(fsSync.fsync).mockImplementationOnce(() => { throw fsyncError }) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index 25d0e7dc89..acffe45b93 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -205,11 +205,17 @@ function _stagingDir(dir: string): string { /** * fsync a file descriptor so its data is durable before the atomic rename. - * Uses the sync form because this repo's @types/node does not declare - * fs.promises.fsync; the staging file is small, so the blocking window is bounded. + * + * The async fsync form runs the same syscall on libuv's thread pool instead of blocking the + * extension host event loop (this is also what FileHandle.sync() uses internally). The staging + * write and fchmod stay synchronous because they are bounded and need the descriptor the sync + * open produced; only the durability barrier - the part the review measured at ~7 ms - is moved + * off the event loop. Any fsync error still rejects the publish before the rename. */ -function _fsyncFile(fd: number): void { - fsSync.fsyncSync(fd) +function _fsyncFile(fd: number): Promise { + return new Promise((resolve, reject) => { + fsSync.fsync(fd, (error) => (error ? reject(error) : resolve())) + }) } /** @@ -575,7 +581,7 @@ export async function safeWriteText(filePath: string, content: string, options?: while (offset < buffer.length) { offset += fsSync.writeSync(fd, buffer, offset, buffer.length - offset) } - _fsyncFile(fd) + await _fsyncFile(fd) } finally { fsSync.closeSync(fd) } @@ -603,7 +609,7 @@ export async function safeWriteText(filePath: string, content: string, options?: _applyTargetOwnership(fd, targetGid, platform) fsSync.fchmodSync(fd, targetMode) } - _fsyncFile(fd) + await _fsyncFile(fd) } finally { fsSync.closeSync(fd) } @@ -723,7 +729,7 @@ export async function safeWriteText(filePath: string, content: string, options?: try { const dirFd = fsSync.openSync(dirPath, "r") try { - _fsyncFile(dirFd) + await _fsyncFile(dirFd) } finally { fsSync.closeSync(dirFd) } From f881e082eebbb0eb9cce7ce9de65654b16b622ce Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:17:51 +0800 Subject: [PATCH 53/60] refactor(file-safety): drop the unused backup option Review follow-up: no production caller passes backup: true, and the option added a copy/retain/cleanup path with its own ~20 tests. Remove it until a caller needs the recovery copy: the primitive keeps the single atomic rename, staging fsync, DACL capture/restore and the dump cleanup. Tests and comments are pruned accordingly (the DACL retry, lost-DACL and ordering coverage stays). Related suites 200 passed, 8 skipped. --- .../__tests__/safeWriteText.spec.ts | 308 +----------------- src/services/file-safety/safeWriteText.ts | 115 +------ 2 files changed, 27 insertions(+), 396 deletions(-) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 1f4e7eecf4..70aabb4214 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -371,111 +371,13 @@ describe("safeWriteText", () => { // 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 + // no recovery copy exists, so the 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).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(1) - - // 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", () => { - // These tests exercise the win32 publish path, whose DACL capture is judged by its - // artifact, so the mock world has to produce a usable dump. - beforeEach(() => { - // Path-aware: this code stats two different paths - the target, for its mode, and the - // dump, to judge the capture. One stub for both paths would let a regression that - // stats the target instead of the dump pass, and would silently stage with mode 0 - // because the stub carries no mode. - vi.mocked(fsSync.statSync).mockImplementation(((p: unknown) => - typeof p === "string" && p.includes(".acl.tmp") - ? { isFile: () => true, size: 256 } - : _stats(0o644)) as never) - }) - it("copies 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.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) - - // second rename: temp -> target (realpath mock returns targetPath) - expect(fs.rename).toHaveBeenCalledTimes(1) - - // backup was deleted on success - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) - }) - - it("a failed publish leaves the target in place and removes the backup copy", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - // the only rename is the publish, and it fails - vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) - - await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("ENOSPC") - - // no rollback rename exists: the target was never moved, so cleanup removes the copy - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) - expect(fs.rename).toHaveBeenCalledTimes(1) - - // temp was cleaned up on failure - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) - }) - - it("backup:true when target does not exist: no backup created, just commit", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - 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 4: commit failure leaves the target untouched ── // ── Test 5: win32 DACL path ────────────────────────────────────────────── @@ -500,7 +402,7 @@ describe("safeWriteText", () => { vi.mocked(fsSync.openSync).mockReturnValue(1) await safeWriteText(targetPath, "data", { platform: "win32" }) - // The neighbouring argument test passes backup:true, so this no-backup case is + // The neighbouring argument test passes a tempPath, so this generated-path case is // kept - but it has to check the commands, not just their count. expect(execFile).toHaveBeenCalledTimes(2) @@ -600,7 +502,7 @@ describe("safeWriteText", () => { await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow("probe failed") expect(execFile).not.toHaveBeenCalled() // Nothing downstream of the probe may run: a swallowed probe error would keep going and - // copy the target to a backup, which is what makes this assertion load-bearing. + // no recovery copy is made, which is what makes this assertion load-bearing. expect(fs.copyFile).not.toHaveBeenCalled() }) @@ -750,38 +652,6 @@ describe("safeWriteText", () => { warn.mockRestore() }) - it("does not retain a backup when the only defect is the missing restore privilege", async () => { - // The DACL limitation is a host capability, not a data-integrity failure, so the - // publish is a success and the backup is not needed as a descriptor-recovery copy. - vi.resetModules() - const { safeWriteText: freshWriteText } = await import("../safeWriteText") - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) - const privilegeFailure = Object.assign(new Error("Access is denied."), { code: 1300 }) - let calls = 0 - const runner = vi.fn((...args: unknown[]) => { - calls++ - const cb = args[args.length - 1] as (err: Error | null) => void - cb(args[1] && String((args[1] as string[])[1]) === "/restore" ? privilegeFailure : null) - }) as unknown as typeof execFile - const options = { platform: "win32", execFileRunner: runner, backup: true } - - await expect(freshWriteText(targetPath, "data", options)).resolves.toBeUndefined() - - expect(calls).toBe(2) - // The content is committed and the backup is cleaned up like any successful publish. - expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) - const copies = vi.mocked(fs.copyFile).mock.calls - expect(copies).toHaveLength(1) - const backupDestination = String(copies[0]?.[1]) - expect( - vi.mocked(fs.unlink).mock.calls.filter((call) => String(call[0]) === backupDestination), - ).toHaveLength(1) - warn.mockRestore() - }) - it("keeps a non-privilege restore failure as the typed post-commit error", async () => { // A restore failure that is not the host capability limit could clear on a retry or // point at a real ACL problem, so it stays an error even though the content committed. @@ -803,17 +673,17 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) - it("win32 DACL save args are [targetPath, /save, dumpPath] before backup rename", async () => { + it("win32 DACL save args are [targetPath, /save, dumpPath] before the commit 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" }) + await safeWriteText(targetPath, "data", { platform: "win32" }) // icacls was called twice (save + restore) expect(execFile).toHaveBeenCalledTimes(2) - // First call: save DACL from target before backup rename + // First call: save DACL from target before the commit rename const firstCall = vi.mocked(execFile).mock.calls[0] expect(firstCall[0]).toBe("icacls") expect(firstCall[1]).toEqual([targetPath, "/save", expect.stringContaining(".acl.tmp")]) @@ -939,51 +809,6 @@ describe("safeWriteText", () => { expect(warned).toContain(".acl.tmp") }) - it("keeps the publish and warns when both backup-removal attempts fail", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - // Only the backup unlink fails, twice; every other unlink works. - const unlinkMock = vi.mocked(fs.unlink) - unlinkMock.mockImplementation(async (p) => { - if (typeof p === "string" && p.includes(".bak")) { - throw new Error("EBUSY") - } - return undefined - }) - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - - await safeWriteText(targetPath, "new data", { backup: true }) - - // The write itself is unaffected - the backup is cleanup, not part of the commit. - expect(fs.rename).toHaveBeenCalled() - const bakCalls = unlinkMock.mock.calls.filter(function (call) { - return typeof call[0] === "string" && call[0].includes(".bak") - }) - expect(bakCalls).toHaveLength(2) - const warned = warnSpy.mock.calls - .map(function (call) { - return String(call[0]) - }) - .join("\n") - expect(warned).toContain("could not be removed") - expect(warned).toContain(".bak") - }) - - it("removes a partially copied backup when the copy itself fails", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - // copyFile can create the destination and then fail part way through. - vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) - - await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("EACCES") - - // The half-written backup must not be left next to the target, and the original error - // is still the one that surfaces. - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".bak")) - }) - it("closes the staging descriptor when applying the target mode fails", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -1000,7 +825,7 @@ describe("safeWriteText", () => { expect(fsSync.writeSync).not.toHaveBeenCalled() }) - it("win32 DACL: when both restore attempts fail, the backup is retained", async () => { + it("win32 DACL: when both restore attempts fail, the publish reports the lost DACL", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -1016,7 +841,7 @@ describe("safeWriteText", () => { }) // The publish reports the lost DACL as the typed error... - await expect(safeWriteText(targetPath, "data", { platform: "win32", backup: true })).rejects.toBeInstanceOf( + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toBeInstanceOf( DaclRestoreError, ) @@ -1024,20 +849,6 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalled() // The dump is still cleaned up in the finally block. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) - // And the backup survives the rejection: it is the only artifact that still carries - // the target's original security descriptor, so deleting it would destroy the - // recovery path. The copy is asserted first: counting the absence of a .bak unlink - // also passes when no backup was ever created, which is not the same fact. - const copies = vi.mocked(fs.copyFile).mock.calls - expect(copies).toHaveLength(1) - const [copiedSource, copiedDestination] = copies[0] ?? [] - expect(String(copiedSource)).toBe(targetPath) - const backupDestination = String(copiedDestination) - expect(backupDestination).toContain("safeWriteText.bak_") - const bakUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call) { - return String(call[0]) === backupDestination - }) - expect(bakUnlinks).toHaveLength(0) }) it("staging temp release: a transient unlink failure on the rollback path is retried", async () => { @@ -1114,14 +925,14 @@ describe("safeWriteText", () => { // descriptor, so the write has to fail closed instead of publishing. vi.mocked(fsSync.statSync).mockReturnValue({ isFile: () => false, size: 4096 } as never) - await expect(safeWriteText(targetPath, "data", { platform: "win32", backup: true })).rejects.toThrow( + await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toThrow( /DaclCaptureError|refus/i, ) expect(fs.rename).not.toHaveBeenCalled() }) - it("win32 DACL: a failed first restore is retried and the backup is then removed", async () => { + it("win32 DACL: a failed first restore is retried", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) @@ -1136,7 +947,7 @@ describe("safeWriteText", () => { return fakeChild }) - await safeWriteText(targetPath, "data", { platform: "win32", backup: true }) + await safeWriteText(targetPath, "data", { platform: "win32" }) const saves = vi.mocked(execFile).mock.calls.filter(function (call) { return String(call[1]).includes("/save") @@ -1147,8 +958,6 @@ describe("safeWriteText", () => { expect(saves.length).toBe(1) expect(restores.length).toBe(2) expect(fs.rename).toHaveBeenCalled() - // The descriptor is back, so the backup is no longer the recovery artifact and is removed. - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".bak")) }) it("win32 DACL: when target does not exist, no save/restore/dump", async () => { @@ -1696,23 +1505,6 @@ describe("safeWriteText", () => { expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) - it("keeps the backup when the commit is not confirmed durable", 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") - }) - - expect(await _rejectionName(safeWriteText(targetPath, "data", { platform: "linux", backup: true }))).toBe( - "PublishNotDurableError", - ) - - // The previous content stays recoverable while durability is unconfirmed. - expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) - }) - it("refuses to stage through a planted .file-safety-staging symlink", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -1938,30 +1730,6 @@ describe("safeWriteText", () => { vi.mocked(fs.realpath).mockRejectedValueOnce(plain) await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(plain) }) - - 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", - ) - }) }) describe("failure paths inside the staging write", () => { @@ -1998,25 +1766,6 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) }) - it("backup:true copies the target instead of moving it, so a failed publish leaves the target in place", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - const publishError = new Error("EXDEV: cross-device rename not permitted") - vi.mocked(fs.rename).mockRejectedValueOnce(publishError) - - await expect(safeWriteText(targetPath, "hello world", { platform: "linux", backup: true })).rejects.toBe( - publishError, - ) - - // The backup is a copy, so the target was never renamed away and there is nothing to roll back. - expect(fs.copyFile).toHaveBeenCalled() - expect(fs.rename).toHaveBeenCalledTimes(1) - // Both the backup copy and the staging temp are removed on failure. - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) - }) - it("removes the DACL dump and aborts when the save fails after creating it", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -2161,39 +1910,6 @@ describe("parent directory creation (safeWriteText.ts:288-290)", () => { }) }) -describe("partial backup after a failed copy (backup:true)", () => { - beforeEach(() => { - // Same isolation reason as the block above: outside the main describe, so nothing - // resets the mocks or restores the defaults for it. - vi.resetAllMocks() - 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(fsSync.statSync).mockReturnValue(_stats(0o644)) - vi.mocked(fsSync.lstatSync).mockReturnValue(_dirStats()) - vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => - typeof args[3] === "number" ? args[3] : 0, - ) - }) - - it("removes the partial backup when copyFile fails with ENOENT and the publish succeeds", async () => { - const targetPath = "/tmp/test-dir/target.txt" - vi.mocked(fs.realpath).mockResolvedValue(targetPath) - vi.mocked(fsSync.openSync).mockReturnValue(1) - // The target exists for the backup pre-check, then the copy itself fails: a partial file - // may already sit at the .bak path while backupCreated stays false. The publish still - // succeeds, and nothing else in the flow would ever remove that half-written backup. - vi.mocked(fs.access).mockResolvedValue(undefined) - vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) - - await safeWriteText(targetPath, "data", { platform: "linux", backup: true }) - - expect(fs.rename).toHaveBeenCalled() - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak")) - }) -}) - describe("staging directory release", () => { beforeEach(() => { _restoreFsDefaults() diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts index acffe45b93..29b4a42af1 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -9,17 +9,6 @@ import { resolveRealPath } from "../../utils/WorkspacePathResolver" * Options for safeWriteText atomic text publish primitive. */ export interface SafeWriteTextOptions { - /** - * When true, copy the target to a backup file before the commit rename. The - * commit is a single atomic rename, so the target is never removed first and - * nothing restores from the copy: it is deleted after a confirmed commit, and - * kept on disk when the commit could not be confirmed durable or the saved - * DACL could not be restored, so the previous content and its security - * descriptor stay recoverable. Costs a full read+write of the old file, so - * callers that do not need that recovery copy should leave this off. - */ - 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 @@ -106,7 +95,7 @@ export class PublishNotDurableError extends Error { /** * The content is committed but a captured DACL could not be put back on it, so the file at the * target answers to different access rights than the one it replaced. Raised after the commit - * rename, so the content is at the target and the backup copy is retained. + * rename, so the content is at the target. * * A host that cannot restore DACLs at all (icacls /restore exits 1300 because the process lacks * the backup/restore privilege) is treated as a logged host limitation instead: the content is @@ -333,12 +322,11 @@ const _ICACLS_PRIVILEGE_EXIT_CODE = 1300 * 1. Write content to a temp file in a private per-write staging subdir * (same volume -> atomic rename guaranteed). * 2. fsync the temp file, then close it. - * 3. win32 only: if target exists save its DACL dump BEFORE backup rename. - * 4. Optionally rename target -> backup (when backup:true). - * 5. Atomic rename temp -> target. - * 6. win32 only: restore DACL onto the directory AFTER commit rename. - * 7. On success: delete backup (if any) and unlink DACL dump. - * 8. On failure: rollback backup to target path; clean up temp + dump. + * 3. win32 only: if target exists save its DACL dump BEFORE the commit rename. + * 4. Atomic rename temp -> target. + * 5. win32 only: restore DACL onto the directory AFTER commit rename. + * 6. On success: unlink DACL dump. + * 7. On failure: clean up temp + dump (the target was never moved). */ /** @@ -507,20 +495,13 @@ export async function safeWriteText(filePath: string, content: string, options?: } } - let backupPath: string | null = null - let backupCreated = false - // Set as soon as the backup destination is chosen, before the copy runs: a copyFile that - // fails part way can still leave a partial file at that path, and it has to be cleaned up - // like a completed backup would be. - let backupAttempted = false // Set when the commit landed but its durability could not be confirmed. The // content is in place, so there is nothing to roll back, but the caller must // not be told the publish is durable. let durabilityError: unknown = null // Set when the commit landed but the saved DACL could not be put back on the published file. // The content is in place, so there is nothing to roll back, but the caller must not observe a - // success whose access rights changed, and the backup is the only artifact still carrying the - // original security descriptor, so it has to survive for recovery. + // success whose access rights changed. let daclRestoreError: DaclRestoreError | null = null let daclDumpPath: string | null = null // tracked for cleanup in finally let daclSaved = false // restore step runs only when the save succeeded @@ -615,7 +596,7 @@ export async function safeWriteText(filePath: string, content: string, options?: } } - // -- Step 2 (win32): save DACL BEFORE backup rename --------------- + // -- Step 2 (win32): save DACL BEFORE the commit rename ----------- // Once a restore has proven this host cannot restore at all, saving a dump it cannot // apply is three process spawns per write for nothing, so the pair is skipped. The // skip is decided here, not in step 5, because the save is the expensive half. @@ -634,7 +615,7 @@ export async function safeWriteText(filePath: string, content: string, options?: // original back. icacls /save does not need the backup/restore privileges that /restore // does (measured: /restore exits 1300 un-elevated), so refusing here does not break // ordinary hosts - it removes the exposure instead of accepting it. The staging file, - // any partial backup and the dump are cleaned by the rollback below. + // the dump is cleaned by the rollback below. daclSaved = false // This throw leaves the try/finally that owns cleanup, so release the artifacts here: // the staged file and the (unusable) dump must not be orphaned. Removal is retried once @@ -685,27 +666,7 @@ export async function safeWriteText(filePath: string, content: string, options?: } try { - // -- Step 3 (backup:true): copy the target to a backup ---------------- - // The target must stay at its canonical path until the single atomic rename - // below publishes the staged file. Renaming the target away first would leave - // the path missing if that publish rename failed, so the backup is a copy and - // there is nothing to roll back. - if (options?.backup) { - try { - await fs.access(targetPath) - backupPath = _tempName(dirPath, "safeWriteText.bak") - backupAttempted = true - await fs.copyFile(targetPath, backupPath) - backupCreated = 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 --------------------- + // -- Step 3: atomic rename temp -> target --------------------- // On Windows the rename can be rejected while another process holds the // target without FILE_SHARE_DELETE. The previous fs.writeFile path tolerated // that, so retry a bounded number of times before failing. @@ -736,7 +697,7 @@ export async function safeWriteText(filePath: string, content: string, options?: } catch (error: unknown) { // The rename committed, so the content is in place and there is nothing to // roll back, but the directory entry is not confirmed durable. Record it so - // the call fails and the backup is kept for recovery. + // the call fails and the publish is reported as not durable. durabilityError = error } } @@ -771,41 +732,11 @@ export async function safeWriteText(filePath: string, content: string, options?: // restrictive ACL on the replacement. Reported as an error rather than a // warning: a publish that changed who can read the file must not look like // an ordinary successful save. Thrown after the rollback handler below - the - // content is committed, so this is not a failure to roll back, and the - // backup has to survive for recovery. + // content is committed, so this is not a failure to roll back. daclRestoreError = new DaclRestoreError(targetPath, null) } } } - - // -- Step 6 (backup:true): delete backup only on a fully successful publish -- - // Kept when durability is unconfirmed (the rename may be lost on power loss) AND when - // the DACL restore failed: that backup is the only artifact still carrying the target's - // original security descriptor, so deleting it would destroy the recovery path. - // A copy that failed part way leaves a partial file at backupPath with backupCreated - // never set, so the condition has to cover backupAttempted too: after a successful - // publish nothing else would ever remove that half-written backup. - if ( - (backupCreated || backupAttempted) && - backupPath && - durabilityError === null && - daclRestoreError === null - ) { - try { - await fs.unlink(backupPath) - } catch { - // One bounded retry: the backup can still be held open by another process right - // after the commit rename. If that fails too the path is surfaced instead of - // silently abandoned - an orphaned backup is acceptable, an invisible one is not. - try { - await fs.unlink(backupPath) - } catch (cleanupError: unknown) { - console.warn( - `safeWriteText: the write to ${targetPath} committed but its backup copy could not be removed at ${backupPath} (${String(cleanupError)}); the copy is left in place.`, - ) - } - } - } } finally { // Unlink DACL dump regardless of success/failure in this span. if (daclDumpPath !== null) { @@ -829,24 +760,9 @@ export async function safeWriteText(filePath: string, content: string, options?: // tempPath is now the committed file; no cleanup needed. } catch (originalError: unknown) { // -- Rollback / cleanup on failure ---------------------------------- - if ((backupCreated || backupAttempted) && backupPath) { - // Covers a copyFile that failed after creating the destination: the partial file - // would otherwise be orphaned next to the target. - // The target was never moved, so rollback is just removing the backup copy. - try { - await fs.unlink(backupPath) - } catch (cleanupError: unknown) { - // Cleanup failure is non-fatal, but an invisible orphan is worse than a visible one: - // the original error is still the one that gets thrown. - console.warn( - `safeWriteText: the write to ${targetPath} failed and its backup copy at ${backupPath} could not be removed (${String(cleanupError)}).`, - ) - } - } - // Always clean up the staging temp file on failure. A single swallowed unlink is not enough: // a transient EBUSY/EPERM would silently leave the temp inside the staging directory, and nothing - // would ever report the retained path. Bounded retry, same shape as the DACL and backup cleanup. + // would ever report the retained path. Bounded retry, same shape as the DACL cleanup. for (let attempt = 0; attempt < 2; attempt++) { try { await fs.unlink(tempPath) @@ -876,15 +792,14 @@ export async function safeWriteText(filePath: string, content: string, options?: await _releaseStagingDir() // Raised outside the rollback handler on purpose: the content is committed, so - // this is not a failure to roll back, and the backup must survive for recovery. + // this is not a failure to roll back. if (durabilityError !== null) { const reason = durabilityError instanceof Error ? durabilityError.message : String(durabilityError) throw new PublishNotDurableError(targetPath, reason) } // Raised outside the rollback handler for the same reason as PublishNotDurableError: the - // content is committed, so this is not a failure to roll back, and the backup retained above - // is the only artifact still carrying the target's original security descriptor. A restore + // content is committed, so this is not a failure to roll back. A restore // failure therefore reaches the caller as its own error: no caller can observe success for a // publish whose DACL was not restored. if (daclRestoreError !== null) { From a56e5666a96dde9873fb56dfcb821f78e834e88c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:34:14 +0800 Subject: [PATCH 54/60] test(file-safety): compare lock paths with the canonical temp root Windows CI returns an 8.3 short os.tmpdir() path, but resolveRealPath returns the long form, so the lock-path assertions now compare against the realpath of the existing ancestor instead of path.resolve. 3 tests passed locally. --- src/utils/__tests__/fileLock.spec.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/fileLock.spec.ts b/src/utils/__tests__/fileLock.spec.ts index 36e3fc4a8f..6241ac99e1 100644 --- a/src/utils/__tests__/fileLock.spec.ts +++ b/src/utils/__tests__/fileLock.spec.ts @@ -31,15 +31,20 @@ describe("resolveFileLockPath", () => { const aliasPath = path.join(aliasDirectory, "settings.json") const referentPath = path.join(realDirectory, "settings.json") + // Compare against the realpath of the ancestor: Windows short (8.3) paths in os.tmpdir() + // resolve to their long form, so path.resolve(tmp) is not the canonical spelling. + const expected = path.join(await fs.realpath(realDirectory), "settings.json") - await expect(resolveFileLockPath(aliasPath)).resolves.toBe(path.resolve(referentPath)) - await expect(resolveFileLockPath(referentPath)).resolves.toBe(path.resolve(referentPath)) + await expect(resolveFileLockPath(aliasPath)).resolves.toBe(expected) + await expect(resolveFileLockPath(referentPath)).resolves.toBe(expected) }) it("keeps an ordinary path unchanged", async () => { const root = await makeTemporaryDirectory() const target = path.join(root, "ordinary.json") - await expect(resolveFileLockPath(target)).resolves.toBe(path.resolve(target)) + // The lock path resolves the nearest existing ancestor (the temp root), so short (8.3) + // Windows temp paths come back in their canonical long form. + await expect(resolveFileLockPath(target)).resolves.toBe(path.join(await fs.realpath(root), "ordinary.json")) }) }) From 99b8b9067c60a58bc46874ae9a840319e15543a7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:25:33 +0800 Subject: [PATCH 55/60] fix(file-safety): treat post-commit publish errors as committed writes for cache state Review follow-up: add isPublishCommittedError(error) for PublishNotDurableError/DaclRestoreError and use it where a consumer keeps state derived from the write. CacheManager.clearCacheFile now clears the in-memory hashes when the empty cache committed but its durability/ACL verification failed (the file on disk is the new one); DiffViewProvider reuses the shared guard. safeWriteText keeps the exact-commit failure contract. Adds slice tests: guard classification, staging mkdirSync EACCES/ENOSPC without open/rename, and the committed-clear cache path. Related suites 205 passed, 8 skipped. --- src/integrations/editor/DiffViewProvider.ts | 5 +- .../__tests__/cache-manager.spec.ts | 21 ++++++-- src/services/code-index/cache-manager.ts | 10 +++- .../__tests__/safeWriteText.spec.ts | 48 ++++++++++++++++++- src/services/file-safety/safeWriteText.ts | 10 ++++ 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index b41ce3b960..f5ef407514 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -21,8 +21,7 @@ import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" import { assertPublishWithinAuthorizedRoot, - DaclRestoreError, - PublishNotDurableError, + isPublishCommittedError, safeWriteText, } from "../../services/file-safety/safeWriteText" @@ -1194,7 +1193,7 @@ export class DiffViewProvider { // writing again. Treat the save as completed so the tool's bookkeeping matches // the disk (and content already on disk is not retried), and surface the // condition as a warning instead. - if (error instanceof PublishNotDurableError || error instanceof DaclRestoreError) { + if (isPublishCommittedError(error)) { console.warn(`saveDirectly: content committed with a warning: ${error.message}`) } else { throw error diff --git a/src/services/code-index/__tests__/cache-manager.spec.ts b/src/services/code-index/__tests__/cache-manager.spec.ts index 77bd5be198..bf9b7f3d5c 100644 --- a/src/services/code-index/__tests__/cache-manager.spec.ts +++ b/src/services/code-index/__tests__/cache-manager.spec.ts @@ -7,9 +7,10 @@ import { CacheManager } from "../cache-manager" import { clearAllMocks } from "../../../test-utils/reset" // Mock safeWriteJson utility -vitest.mock("../../../utils/safeWriteJson", () => ({ - safeWriteJson: vitest.fn().mockResolvedValue(undefined), -})) +vitest.mock("../../../utils/safeWriteJson", async () => { + const actual = await vi.importActual("../../../utils/safeWriteJson") + return { ...actual, safeWriteJson: vi.fn().mockResolvedValue(undefined) } +}) // Import the mocked version import { safeWriteJson } from "../../../utils/safeWriteJson" @@ -184,6 +185,20 @@ describe("CacheManager", () => { expect(cacheManager.getAllHashes()).toEqual({}) }) + it("clears in-memory hashes when the empty cache committed but its durability was unconfirmed", async () => { + cacheManager.updateHash("test.ts", "hash") + const { PublishNotDurableError } = await import("../../file-safety/safeWriteText") + const consoleWarnSpy = vitest.spyOn(console, "warn").mockImplementation(function () {}) + ;(safeWriteJson as Mock).mockRejectedValue(new PublishNotDurableError(mockCachePath.fsPath, "EIO")) + + await cacheManager.clearCacheFile() + + // The empty object is already on disk, so the old hashes must not survive in memory. + expect(cacheManager.getAllHashes()).toEqual({}) + expect(consoleWarnSpy).toHaveBeenCalled() + consoleWarnSpy.mockRestore() + }) + it("should handle clear errors gracefully", async () => { const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(function () {}) ;(safeWriteJson as Mock).mockRejectedValue(new Error("Save failed")) diff --git a/src/services/code-index/cache-manager.ts b/src/services/code-index/cache-manager.ts index 7807aac69d..4bd1602594 100644 --- a/src/services/code-index/cache-manager.ts +++ b/src/services/code-index/cache-manager.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode" import { createHash } from "crypto" import { ICacheManager } from "./interfaces/cache" import debounce from "lodash.debounce" -import { safeWriteJson } from "../../utils/safeWriteJson" +import { isPublishCommittedError, safeWriteJson } from "../../utils/safeWriteJson" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" @@ -70,6 +70,14 @@ export class CacheManager implements ICacheManager { await safeWriteJson(this.cachePath.fsPath, {}) this.fileHashes = {} } catch (error) { + if (isPublishCommittedError(error)) { + // The empty cache object is already committed on disk: only its durability or the + // restored access rights could not be confirmed. Keeping the old in-memory hashes + // would make the process disagree with the file it just published. + this.fileHashes = {} + console.warn("Cache file content committed but not fully verified:", error) + return + } console.error("Failed to clear cache file:", error, this.cachePath) TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { error: error instanceof Error ? error.message : String(error), diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 70aabb4214..cbe900a3ad 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 { DaclRestoreError, resolvePublishTarget, safeWriteText } from "../safeWriteText" +import { + DaclCaptureError, + DaclRestoreError, + isPublishCommittedError, + PublishNotDurableError, + resolvePublishTarget, + safeWriteText, +} from "../safeWriteText" // The two failure classes are module-private (knip ignores __tests__, so a // test-only export would be reported as unused), so tests match them by name. @@ -1977,3 +1984,42 @@ describe("post-create staging probe failure", () => { expect(fsSync.rmdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) }) }) + +describe("isPublishCommittedError", () => { + it("marks the two post-commit failures as committed writes", () => { + expect(isPublishCommittedError(new PublishNotDurableError("/tmp/target.txt", "EIO"))).toBe(true) + expect(isPublishCommittedError(new DaclRestoreError("/tmp/target.txt", null))).toBe(true) + }) + + it("does not mark pre-commit failures or plain errors as committed", () => { + expect(isPublishCommittedError(new Error("EACCES: permission denied"))).toBe(false) + expect(isPublishCommittedError(new DaclCaptureError("/tmp/target.txt", "/tmp/dump"))).toBe(false) + expect(isPublishCommittedError(undefined)).toBe(false) + }) +}) + +describe("staging directory creation failure", () => { + beforeEach(() => { + _restoreFsDefaults() + }) + + it.each([ + ["EACCES", Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })], + ["ENOSPC", Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC" })], + ])("reports a %s mkdir failure without opening or renaming anything", async (_code, mkdirError) => { + vi.mocked(fs.realpath).mockResolvedValue("/tmp/test-dir/target.txt") + const mkdirMock = vi.mocked(fsSync.mkdirSync) + mkdirMock.mockClear() + mkdirMock.mockImplementationOnce(() => { + throw mkdirError + }) + vi.mocked(fsSync.openSync).mockClear() + vi.mocked(fs.rename).mockClear() + + await expect(safeWriteText("/tmp/test-dir/target.txt", "data", { platform: "linux" })).rejects.toBe(mkdirError) + + // The exact failure reaches the caller and no publish work starts after it. + 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 29b4a42af1..571d345098 100644 --- a/src/services/file-safety/safeWriteText.ts +++ b/src/services/file-safety/safeWriteText.ts @@ -114,6 +114,16 @@ export class DaclRestoreError extends Error { } } +/** + * True when the atomic rename already committed the content even though the publish reported + * an error: the directory entry is not confirmed durable, or the saved DACL could not be put + * back. Callers must treat the write as committed (update their in-memory state and never retry + * the content), while still surfacing the unconfirmed durability/access-rights condition. + */ +export function isPublishCommittedError(error: unknown): error is PublishNotDurableError | DaclRestoreError { + return error instanceof PublishNotDurableError || error instanceof DaclRestoreError +} + function _errorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code From ea34e2d5fb86ddc10e78f50760e0e518744a015a Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:54:33 +0800 Subject: [PATCH 56/60] test(file-safety): pin lock contention and the post-commit cache warning Review follow-ups: the alias/referent lock test now asserts the ELOCKED failure kind (lock contention, not an unrelated lockfile error) and releases both the holder and, should it ever acquire, the contending lock from a finally block so a failing assertion cannot strand the lock. The committed-clear cache test keeps the PublishNotDurableError instance and asserts the warning receives the exact message prefix plus that instance. fileLock + cache-manager specs 15 passed. --- .../__tests__/cache-manager.spec.ts | 11 ++++++--- src/utils/__tests__/fileLock.spec.ts | 23 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/services/code-index/__tests__/cache-manager.spec.ts b/src/services/code-index/__tests__/cache-manager.spec.ts index bf9b7f3d5c..c695cf4b43 100644 --- a/src/services/code-index/__tests__/cache-manager.spec.ts +++ b/src/services/code-index/__tests__/cache-manager.spec.ts @@ -189,13 +189,18 @@ describe("CacheManager", () => { cacheManager.updateHash("test.ts", "hash") const { PublishNotDurableError } = await import("../../file-safety/safeWriteText") const consoleWarnSpy = vitest.spyOn(console, "warn").mockImplementation(function () {}) - ;(safeWriteJson as Mock).mockRejectedValue(new PublishNotDurableError(mockCachePath.fsPath, "EIO")) + const committedError = new PublishNotDurableError(mockCachePath.fsPath, "EIO") + ;(safeWriteJson as Mock).mockRejectedValue(committedError) await cacheManager.clearCacheFile() - // The empty object is already on disk, so the old hashes must not survive in memory. + // The empty object is already on disk, so the old hashes must not survive in memory, + // and the warning names the exact post-commit error the consumer had to tolerate. expect(cacheManager.getAllHashes()).toEqual({}) - expect(consoleWarnSpy).toHaveBeenCalled() + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Cache file content committed but not fully verified:", + committedError, + ) consoleWarnSpy.mockRestore() }) diff --git a/src/utils/__tests__/fileLock.spec.ts b/src/utils/__tests__/fileLock.spec.ts index 6241ac99e1..3cdfc995be 100644 --- a/src/utils/__tests__/fileLock.spec.ts +++ b/src/utils/__tests__/fileLock.spec.ts @@ -72,9 +72,24 @@ describe("acquireFileLock", () => { await release }) - await entered - await expect(acquireFileLock(referentPath)).rejects.toThrow() - releaseOperation() - await holding + let contention: Promise<() => Promise> | undefined + let acquiredRelease: (() => Promise) | undefined + try { + await entered + // Pin the failure kind: proper-lockfile rejects with ELOCKED, so this asserts lock + // contention rather than an unrelated lockfile failure. + contention = acquireFileLock(referentPath) + await expect(contention).rejects.toMatchObject({ code: "ELOCKED" }) + } finally { + releaseOperation() + await holding + // If the second acquisition unexpectedly succeeded despite the assertion above, release + // it as well: a failing assertion must not strand the lock for the rest of the suite. + acquiredRelease = await contention?.then( + (releaseLock) => releaseLock, + () => undefined, + ) + await acquiredRelease?.() + } }, 15_000) }) From 80754a7a54b7fe262382399aab4d79631718e136 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:04:10 +0800 Subject: [PATCH 57/60] fix(utils): hand the file-lock operation the caller's own path spelling withFileLock canonicalised the path before handing it to the operation, so a caller whose own path arrives through an 8.3 alias or a junctioned ancestor ran its body against a different string for the same file. The lock key and the artefact stay on resolveFileLockPath - an alias and its referent still contend - and only the path the operation sees changes, because the delete path compares what it receives against names it built from its own string: the backup guard and the leftover-prefix match in jsonFileRecovery are string comparisons, which is why TaskHistoryStore.deleteSemantics.spec.ts loses three rows under an aliased TEMP (two at :145 opening the caller-spelled history file, one at :255 on batch-b) and passes 10/10 under a plain TEMP with the same code. The added row in fileLock.spec.ts pins the caller's spelling contract for both spellings of one referent. Its fixture is a junction (a directory symlink off win32), so it discriminates spelling divergence generally rather than the 8.3 case specifically: it also reddens under a plain TEMP on the head implementation, and 3-failed-under-alias against 10-passed-under-plain on the deletion spec is the pair that isolates the 8.3 alias reason. The identity-backed key used on the 2368 lane is deliberately not ported here: resolveFileLockPath already returns one string for both spellings on this branch, stat identity is one referent across three spellings, and the alias/referent contention row passes under an aliased TEMP with and without this change, so an identity key here would be code no test in this lane can turn red. Measured on Windows with node v22.23.4 (.tmp-node22), vitest.core.config.ts for core/** and vitest.unit.config.ts for utils/** and services/**, run from src, with TEMP pointing at a scratch root whose 8.3 name was read from the parent directory listing: head implementation - deleteSemantics 3 failed | 7 passed under alias, 10 passed under plain; with the fix - 10 passed under both, fileLock.spec.ts 4 passed under both, jsonFileRecovery 10 passed, safeWriteJson 53 passed, whole core/task-persistence 208 passed, whole utils 869 passed | 1 skipped, whole services/file-safety 99 passed | 1 skipped. Swapped in main's fileLock blob 49807ea154d4 on this tree: deleteSemantics 10 passed under both, safeWriteJson 53, jsonFileRecovery 10. This file dropped onto main at 5319fe91fb0c: deleteSemantics 10 passed under both, safeWriteJson 53 under both, jsonFileRecovery 10 under alias. Hosted eslint from src exits 0, per-file eslint --prune-suppressions exits 0 on both files with src/eslint-suppressions.json restored byte-for-byte, repo root prettier --check . exits 0, and tsc --noEmit from src reports no errors. --- src/utils/__tests__/fileLock.spec.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/utils/__tests__/fileLock.spec.ts b/src/utils/__tests__/fileLock.spec.ts index 3cdfc995be..1f8580da11 100644 --- a/src/utils/__tests__/fileLock.spec.ts +++ b/src/utils/__tests__/fileLock.spec.ts @@ -92,4 +92,30 @@ describe("acquireFileLock", () => { await acquiredRelease?.() } }, 15_000) + + it("hands the operation the caller's own spelling rather than the canonical one", async () => { + const root = await makeTemporaryDirectory() + const realDirectory = path.join(root, "real") + const aliasDirectory = path.join(root, "alias") + await fs.mkdir(realDirectory) + await fs.symlink(realDirectory, aliasDirectory, process.platform === "win32" ? "junction" : "dir") + const aliasPath = path.join(aliasDirectory, "settings.json") + const referentPath = path.join(realDirectory, "settings.json") + + // The lock is keyed on the canonical path above, so an alias and its referent contend. The + // operation must still see the spelling it was called with: the delete path compares what it + // receives against names it built from its own string (the backup guard, the leftover + // prefix), and an alias makes the canonical spelling a different string for one file. + const seen: string[] = [] + await withFileLock(aliasPath, async (lockedPath) => { + seen.push(lockedPath) + return undefined + }) + await withFileLock(referentPath, async (lockedPath) => { + seen.push(lockedPath) + return undefined + }) + + expect(seen).toEqual([path.resolve(aliasPath), path.resolve(referentPath)]) + }) }) From 22c651fab3ef4b4138fcc1eaa425042132f9d945 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:15:52 +0800 Subject: [PATCH 58/60] test(file-safety, utils, editor): answer 1395's eleven test-only review rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every row this commit acts on is a spec row: no production line moves, so the mutation gate's inputs are unchanged (the gate's own selectFromGit still counts 359 executable changed lines of the 500 cap against base 73931fe8be25). Rows answered by code (review comment ids, in thread order): - 4510971744 DiffViewProvider.spec.ts: pin the target paths handed to the authorization preflight, which otherwise rejects whatever it is given. - 4510971771 hold the staged-file fsync callback open and assert closeSync and the commit rename are waiting, then release it and assert both run. - 4510971780 hold fs.rename pending and assert only /save has run, then resolve it and assert /restore follows: the DACL restore cannot precede the commit. - 4510971785 add the split-character short write ('é' is 1 character, 2 bytes) so a loop bounded by content.length cannot pass. - 4510971793 assert the last write precedes the first fsync, not just its arguments. - 4510971800 assert fs.rename never ran on both the writeSync- and fsync-failure paths. - 4510971809 inject fsync failures through the callback for the staged file (pre- commit, nothing published) and the parent directory (post-commit, not durable). - 4510971818 hold each parent-directory preflight pending and assert mkdirSync, openSync and rename never started, before and after the rejection. - 4510971827 assert the rollback and cleanup never unlink the target's own path. - 4510971833 judge the staged "w" open apart from the post-commit directory open. - 4510971849 race the lock-entered signal with the contending acquisition so a failed acquisition surfaces as its own error instead of a 15s test timeout. Measured on node v22.23.4 in this worktree: services lane 104 passed / 1 skipped (POSIX-only case) for safeWriteText.spec.ts; misc lane 82 passed for fileLock.spec and DiffViewProvider.spec.ts; mapped tsc -p src/tsconfig.json --noEmit exit 0; eslint --prune-suppressions --max-warnings=0 exit 0 for each file with src/eslint-suppressions.json restored byte-identically. --- .../editor/__tests__/DiffViewProvider.spec.ts | 8 + .../__tests__/safeWriteText.spec.ts | 373 ++++++++++++++++++ src/utils/__tests__/fileLock.spec.ts | 5 +- 3 files changed, 385 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 397e92d0d4..c35387b5f9 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -793,6 +793,7 @@ describe("DiffViewProvider", () => { const { createDirectoriesForFile } = await import("../../../utils/fs") vi.mocked(createDirectoriesForFile).mockClear() vi.mocked(safeWriteText).mockClear() + vi.mocked(assertPublishWithinAuthorizedRoot).mockClear() const escape = new Error("safeWriteText: refusing to publish outside the authorized root") vi.mocked(assertPublishWithinAuthorizedRoot).mockRejectedValueOnce(escape) @@ -805,6 +806,13 @@ describe("DiffViewProvider", () => { // already have created the outside directory the check exists to protect. expect(createDirectoriesForFile).not.toHaveBeenCalled() expect(safeWriteText).not.toHaveBeenCalled() + // The stub rejects whatever it is handed, so without pinning the arguments this test + // would still pass if the guard were given the workspace root instead of the target. + expect(assertPublishWithinAuthorizedRoot).toHaveBeenCalledWith( + mockCwd, + `${mockCwd}/linkdir/new/secret.txt`, + `${mockCwd}/linkdir/new/secret.txt`, + ) vi.mocked(assertPublishWithinAuthorizedRoot).mockResolvedValue(undefined) }) it("rejects a save over a read-only target instead of renaming onto it", async () => { diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index cbe900a3ad..5b07bffb11 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -359,6 +359,34 @@ describe("safeWriteText", () => { // the parent-directory fsync is the second fsync and lands AFTER the rename expect(vi.mocked(fsSync.fsync).mock.invocationCallOrder[1]).toBeGreaterThan(renameOrder) }) + + it("holds closeSync and the commit rename until the staged-file fsync callback fires", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // Capture the durability barrier instead of letting the default mock answer it synchronously: + // only an open barrier can show that publication waits for the callback. + let stagedFsyncDone!: (error: Error | null) => void + vi.mocked(fsSync.fsync).mockImplementationOnce((_fd: number, callback: (error: Error | null) => void) => { + stagedFsyncDone = callback + }) + + const publishing = safeWriteText(targetPath, "data", { platform: "linux" }) + // One macrotask flush lands the call inside _fsyncFile and no further: every step before + // the barrier awaits an already-resolved mock. + await new Promise((resolve) => setImmediate(resolve)) + + expect(vi.mocked(fsSync.fsync).mock.calls.length).toBe(1) + // While the barrier is unanswered the descriptor stays open and nothing is published. + expect(fsSync.closeSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + + stagedFsyncDone(null) + await publishing + + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) }) // ── Test 3: simulated failure between write and rename leaves target intact ── @@ -377,6 +405,8 @@ describe("safeWriteText", () => { // temp file was cleaned up on failure expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + // The rollback removes the staged temp; it must never reach for the target's own path. + expect(fs.unlink).not.toHaveBeenCalledWith(targetPath) // no recovery copy exists, so the target is untouched // The only rename call was temp→target, not a rollback rename @@ -668,6 +698,17 @@ describe("safeWriteText", () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) vi.mocked(fsSync.openSync).mockReturnValue(1) + // The DACL capture is judged by its artifact, so this describe has to produce one: a regular, + // non-empty dump. The stub is path-aware because the code stats two different paths - the + // target for its mode and the dump to judge the capture - and one stub for both would let a + // The DACL capture is judged by its artifact, so this test has to produce one: a regular, + // non-empty dump. The stub is path-aware because the code stats two different paths - the + // target, for its mode, and the dump, to judge the capture; one stub for both would let a + // regression that stats the target instead of the dump pass. + vi.mocked(fsSync.statSync).mockImplementation(((p: unknown) => + typeof p === "string" && p.includes(".acl.tmp") + ? { isFile: () => true, size: 256 } + : _stats(0o644)) as never) const restoreFailure = new Error("icacls failed for an unrelated reason") const runner = vi.fn((...args: unknown[]) => { const cb = args[args.length - 1] as (err: Error | null) => void @@ -708,6 +749,34 @@ describe("safeWriteText", () => { expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) }) + it("win32 DACL restore waits for the commit rename to resolve", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + let finishRename!: () => void + vi.mocked(fs.rename).mockImplementationOnce( + () => + new Promise((resolve) => { + finishRename = resolve + }), + ) + + const publishing = safeWriteText(targetPath, "data", { platform: "win32" }) + await new Promise((resolve) => setImmediate(resolve)) + + // The capture has run and the restore has not: a refactor that put the restore before the + // commit would already show a /restore call here. + const whileRenaming = vi.mocked(execFile).mock.calls.map((call) => String((call[1] as string[])[1])) + expect(whileRenaming).toEqual(["/save"]) + expect(fs.rename).toHaveBeenCalledTimes(1) + + finishRename() + await publishing + + const afterCommit = vi.mocked(execFile).mock.calls.map((call) => String((call[1] as string[])[1])) + expect(afterCommit).toEqual(["/save", "/restore"]) + }) + it("two concurrent writes to the same target use different DACL dumps", async () => { const targetPath = "/tmp/test-dir/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -1475,6 +1544,40 @@ describe("safeWriteText", () => { expect(fsSync.writeSync).toHaveBeenNthCalledWith(2, 1, buffer, 4, 6) expect(fsSync.fsync).toHaveBeenCalledWith(1, expect.any(Function)) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // The assertions above are about the arguments; these are about WHEN. A barrier that ran + // after only the first (short) write would publish the last 6 bytes unfsynced and keep every + // assertion above green. + expect(vi.mocked(fsSync.writeSync).mock.invocationCallOrder[1]).toBeLessThan( + vi.mocked(fsSync.fsync).mock.invocationCallOrder[0], + ) + expect(vi.mocked(fsSync.fsync).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fs.rename).mock.invocationCallOrder[0], + ) + }) + + it("loops on a short write that splits a multi-byte character", async () => { + const targetPath = "/tmp/test-dir/target.txt" + // "é" is one JavaScript character but two UTF-8 bytes: a loop bounded by content.length + // would stop after the first byte and publish half a character. + const content = "é" + const buffer = Buffer.from(content, "utf8") + expect(buffer.length).toBe(2) + expect(content.length).toBe(1) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // One byte per call: the smallest possible short write. + vi.mocked(fsSync.writeSync).mockImplementation(() => 1) + + await safeWriteText(targetPath, content, { platform: "linux" }) + + expect(fsSync.writeSync).toHaveBeenCalledTimes(2) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(1, 1, buffer, 0, 2) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(2, 1, buffer, 1, 1) + expect(vi.mocked(fsSync.writeSync).mock.invocationCallOrder[1]).toBeLessThan( + vi.mocked(fsSync.fsync).mock.invocationCallOrder[0], + ) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) }) it("fsyncs the parent directory after the commit rename on POSIX", async () => { @@ -1754,6 +1857,7 @@ describe("safeWriteText", () => { // The descriptor opened for the staging file must still be closed. expect(fsSync.closeSync).toHaveBeenCalledWith(1) // Nothing may be renamed into the target position after a partial write. + expect(fs.rename).not.toHaveBeenCalled() // The truncated staging file is removed on failure. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) }) @@ -1771,6 +1875,47 @@ describe("safeWriteText", () => { expect(fsSync.closeSync).toHaveBeenCalledWith(1) expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + // Pre-commit failure: the commit rename never ran. + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("rejects when the staged-file fsync reports through its callback, before any publish", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const callbackError = new Error("EIO: staged fsync callback failed") + // The operating system delivers this failure as the callback's argument; a wrapper that only + // handles a thrown fsync would resolve the barrier and publish an unsynced file. + vi.mocked(fsSync.fsync).mockImplementationOnce((_fd: number, callback: (error: Error | null) => void) => + callback(callbackError), + ) + + await expect(safeWriteText(targetPath, "hello world", { platform: "linux" })).rejects.toBe(callbackError) + + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + // Pre-commit failure: nothing was renamed and the truncated staging file is removed. + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + + it("reports the publish as not durable when the directory fsync reports through its callback", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValueOnce(1).mockReturnValue(2) + const dirError = new Error("EIO: directory fsync callback failed") + // fd 2 is the parent directory opened after the commit rename; fd 1 is the staged file. + vi.mocked(fsSync.fsync).mockImplementation((fd: number, callback: (error: Error | null) => void) => { + callback(fd === 2 ? dirError : null) + }) + + await expect(_rejectionName(safeWriteText(targetPath, "data", { platform: "linux" }))).resolves.toBe( + "PublishNotDurableError", + ) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + // The content is committed, so the staged file (now the target) is never unlinked. + expect(fs.unlink).not.toHaveBeenCalled() + expect(fsSync.closeSync).toHaveBeenCalledWith(2) }) it("removes the DACL dump and aborts when the save fails after creating it", async () => { @@ -1905,6 +2050,61 @@ describe("parent directory creation (safeWriteText.ts:288-290)", () => { await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(err) }) + it("waits on the mkdir preflight before staging, opening or publishing anything", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const err = Object.assign(new Error("EACCES: cannot create"), { code: "EACCES" }) + let settleMkdir!: (error: Error) => void + // mkdir resolves with the directory it created, so the stub has to resolve that type. + vi.mocked(fs.mkdir).mockImplementation( + () => + new Promise((resolve, reject) => { + settleMkdir = (e) => (e ? reject(e) : resolve(undefined)) + }), + ) + + const publishing = safeWriteText(targetPath, "data", { platform: "linux" }) + await new Promise((resolve) => setImmediate(resolve)) + + // While the parent directory is unresolved nothing below it has started: mkdirSync and + // openSync are exactly the staging steps this preflight exists to hold back. + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + + settleMkdir(err) + await expect(publishing).rejects.toBe(err) + + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("waits on the parent-directory access preflight before staging, opening or publishing anything", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const err = Object.assign(new Error("EACCES: cannot verify"), { code: "EACCES" }) + let settleAccess!: (error: Error) => void + vi.mocked(fs.access).mockImplementation( + () => + new Promise((resolve, reject) => { + settleAccess = (e) => (e ? reject(e) : resolve()) + }), + ) + + const publishing = safeWriteText(targetPath, "data", { platform: "linux" }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + + settleAccess(err) + await expect(publishing).rejects.toBe(err) + + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + it("creates and verifies the parent directory on the successful path", async () => { const targetPath = "/tmp/test-dir/nested/target.txt" vi.mocked(fs.realpath).mockResolvedValue(targetPath) @@ -2023,3 +2223,176 @@ describe("staging directory creation failure", () => { expect(fs.rename).not.toHaveBeenCalled() }) }) + +/** + * The six tests below were added by this branch to src/utils/__tests__/safeWriteJson.test.ts, + * where they exercised safeWriteJson's delegation into this primitive: they drove safeWriteJson, + * asserted on the delegated staged path, and pinned the classification a json caller made from the + * error it received. upstream/main (73931fe8be) put its own replacement protocol, interrupted-write + * recovery and EPERM two-step fallback back into safeWriteJson, and this merge keeps main's flow for + * that file rather than the delegation. The requirements those six tests carried are properties of + * this primitive, not of the json wrapper, so they are re-pointed here and drive safeWriteText + * directly - each one keeps its title, and the staged path is now this layer's own staging name or + * the caller-supplied tempPath rather than the path a json caller used to hand in. + * + * None of the six asserts something only safeWriteJson could do: the two injected error strings the + * originals threw ("Rename from temp to final failed", "Primary rename failed") were fixtures the old + * spec put on fs.rename, not messages either module emits, so re-pointing them changes no expected + * wording. They are kept because each pins a half the existing tests here do not: + * - the DACL tests at 'keeps a non-privilege restore failure...' prove the rejection type, but not + * that the staged path is left alone once the commit rename landed; + * - 'refuses to stage when the target mode is unknown' proves the rejection, but not that nothing + * was opened before the mode was known; + * - the copyFile assertions here sit inside DACL and symlink-cycle tests, not on the plain success + * path, which is where "one rename, no backup copy" is a claim about the commit protocol; + * - the rename-failure tests cover the retry policy, not that the target is never a rename source; + * - nothing here covered cleanup of a caller-supplied tempPath, which is the shape a delegating + * caller uses and the reason the old spec said text's own failure test 'only covers the temp path + * it generates itself'; + * - 'a write through a symlink commits onto the resolved referent' pins the commit destination but + * not the directory the staged file was created in. + */ +describe("guarded-publish requirements carried over from the json spec", () => { + beforeEach(() => { + // This describe sits outside the file's main describe, so it restores the shared fs stubs itself. + _restoreFsDefaults() + }) + + it("does not treat the staged path as a leftover when the commit landed but the DACL restore failed", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // regression that stats the target instead of the dump pass. + vi.mocked(fsSync.statSync).mockImplementation(((p: unknown) => + typeof p === "string" && p.includes(".acl.tmp") + ? { isFile: () => true, size: 256 } + : _stats(0o644)) as never) + const restoreFailure = new Error("icacls failed for an unrelated reason") + const runner = vi.fn((...args: unknown[]) => { + const cb = args[args.length - 1] as (err: Error | null) => void + cb(args[1] && String((args[1] as string[])[1]) === "/restore" ? restoreFailure : null) + }) as unknown as typeof execFile + + let caught: unknown = null + try { + await safeWriteText(targetPath, "data", { platform: "win32", execFileRunner: runner }) + } catch (error: unknown) { + caught = error + } + + expect(caught).toBeInstanceOf(DaclRestoreError) + // The classification is what the json caller used this for: a landed commit whose DACL was not + // restored must not be handled as a failed publish with a leftover staged file. + expect(isPublishCommittedError(caught)).toBe(true) + + const staged = String(vi.mocked(fs.rename).mock.calls[0][0]) + expect(vi.mocked(fs.rename).mock.calls[0][1]).toBe(targetPath) + // The commit rename consumed the staged path, so the post-commit catch may unlink the dump but + // must not unlink the name that is now the committed target. + expect(vi.mocked(fs.unlink).mock.calls.filter((call) => String(call[0]) === staged)).toEqual([]) + }) + + it("surfaces a stat failure other than ENOENT instead of staging with the default mode", 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 + }) + + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eio) + + // The half the EACCES test does not pin: the failure has to surface before anything is opened, + // because a staged file created with the wide default mode would sit beside a restrictive target + // for the whole duration of the write. + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("does not copy the target before the commit: the rename is already atomic", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "new content", { platform: "linux" }) + + // A backup copy would be a full extra read and write of the old file on every persistence step, + // and nothing restores from it: the commit is one rename, so the target is intact until it lands. + expect(fs.copyFile).not.toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.unlink).not.toHaveBeenCalled() + }) + + it("a failed publish leaves the target in place because the backup is a copy", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const renameFailure = new Error("commit rename failed") + vi.mocked(fs.rename).mockRejectedValueOnce(renameFailure) + + await expect(safeWriteText(targetPath, "new content", { platform: "linux" })).rejects.toBe(renameFailure) + + // The target is never a rename source, so the old bytes stay where they are: the only rename + // attempted is the publish itself - there is no backup rename and no rollback rename. + expect(fs.rename).not.toHaveBeenCalledWith(targetPath, expect.anything()) + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.copyFile).not.toHaveBeenCalled() + // Cleanup runs on the staged temp only: the target's own path must never be unlinked, + // because the failed publish is precisely the case where the old bytes still live there. + expect(fs.unlink).not.toHaveBeenCalledWith(targetPath) + }) + + it("propagates the publish failure and removes the staged .new_ file", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const stagedPath = path.join(_dirPath(targetPath), ".target.txt.new_1_def.tmp") + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const primaryFailure = new Error("Primary rename failed") + vi.mocked(fs.rename).mockRejectedValueOnce(primaryFailure) + + const options = { tempPath: stagedPath, platform: "linux" } + await expect(safeWriteText(targetPath, "new content", options)).rejects.toBe(primaryFailure) + + // The caller staged this path, so this layer owes its cleanup: a staged file left behind is + // invisible to the caller's own temp handling, which only tracks names it generated itself. + expect(vi.mocked(fs.unlink).mock.calls.some((call) => String(call[0]) === stagedPath)).toBe(true) + expect(fs.rename).not.toHaveBeenCalledWith(targetPath, expect.anything()) + }) + + it("stages the temp file beside the symlink referent and commits onto it", 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, "after", { platform: "linux" }) + + // Both halves follow the referent: the staged file is created under the referent's directory, + // not beside the caller's alias, and the commit renames onto the referent rather than the link. + // The comparison uses the same construction the code under test uses - path.dirname of the + // resolved target - because wrapping either side in path.resolve() here would add this process's + // current drive letter to one side only and make the two values incomparable on Windows. + // Judge the two opens apart: the post-commit directory fsync opens the referent's + // directory too, so a prefix probe over every open passes even if the staged file itself + // were written elsewhere. The "w" open is the staged file, and the commit must rename + // that exact path onto the referent rather than merely some temp-looking name. + // Judge the two opens apart. The post-commit directory fsync opens the referent's directory + // too, so a prefix probe over every open can pass on the wrong open: measured on Windows, the + // only call that matched path.dirname(referentPath) was the directory open ("/tmp/targets"), + // while the staged file came back as "\tmp\targets\.file-safety-staging_…\safeWriteText_…tmp" + // because path.join swaps to backslashes and path.dirname keeps the resolved target's slashes. + // So probe the "w" open alone, and swap separators on both sides only - path.resolve would + // prefix this process's drive letter to one side and make the two values incomparable. + const swapSeparators = (p: string) => p.replaceAll("\\", "/") + const opens = vi.mocked(fsSync.openSync).mock.calls + const stagedOpen = opens.find((call) => call[1] === "w") + expect(stagedOpen).toBeDefined() + const stagedPath = String(stagedOpen?.[0]) + expect(swapSeparators(stagedPath).startsWith(`${swapSeparators(path.dirname(referentPath))}/`)).toBe(true) + expect(swapSeparators(stagedPath).startsWith(`${swapSeparators(path.dirname(linkPath))}/`)).toBe(false) + // The commit renames that exact staged path onto the referent, not merely some temp-looking name. + expect(fs.rename).toHaveBeenCalledWith(stagedPath, referentPath) + }) +}) diff --git a/src/utils/__tests__/fileLock.spec.ts b/src/utils/__tests__/fileLock.spec.ts index 1f8580da11..c8f08732fd 100644 --- a/src/utils/__tests__/fileLock.spec.ts +++ b/src/utils/__tests__/fileLock.spec.ts @@ -75,7 +75,10 @@ describe("acquireFileLock", () => { let contention: Promise<() => Promise> | undefined let acquiredRelease: (() => Promise) | undefined try { - await entered + // Race, do not await: if the acquisition itself fails, signalEntered never runs, and + // awaiting `entered` alone would surface this test's 15-second timeout (plus an unhandled + // rejection) instead of the lock error that actually happened. + await Promise.race([entered, holding]) // Pin the failure kind: proper-lockfile rejects with ELOCKED, so this asserts lock // contention rather than an unrelated lockfile failure. contention = acquireFileLock(referentPath) From daee64d6fc880d3253adb52c691c4174767049c4 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:54:04 +0800 Subject: [PATCH 59/60] restore 1395's own cache-manager import of isPublishCommittedError from safeWriteText The replay onto main applied the cache-state commit's diff against main's copy of src/services/code-index/cache-manager.ts, where the import line is still the single combined import from utils/safeWriteJson. In the branch the diff was authored against, that line had already been rewritten by the branch's own merge of upstream main, commit 1f8ae7fd0a, which a replay drops by design because rebase replays non-merge commits only. The result was a combined import that asks utils/safeWriteJson for isPublishCommittedError, while this unit moved that symbol into services/file-safety/safeWriteText. Measured rather than inferred: main's blob for this path is 7807aac69d1a and the merge base's is the same 7807aac69d1a, so main carried no change here at all, while the unit's head carries b1062de55688. Under the rule that trunk's blob is taken where the unit only carried trunk content and the unit's own content is kept where main does not carry it, this path belongs to the unit, so the file is restored by taking the unit head's blob byte for byte rather than by writing the line again. After this the replayed tree is byte-identical to the tree the textual merge produced, 48b78e885d949b821a46c5c3528d9052b3664995, over 11 files differing from main and no others. --- src/services/code-index/cache-manager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/code-index/cache-manager.ts b/src/services/code-index/cache-manager.ts index 4bd1602594..b1062de556 100644 --- a/src/services/code-index/cache-manager.ts +++ b/src/services/code-index/cache-manager.ts @@ -2,7 +2,8 @@ import * as vscode from "vscode" import { createHash } from "crypto" import { ICacheManager } from "./interfaces/cache" import debounce from "lodash.debounce" -import { isPublishCommittedError, safeWriteJson } from "../../utils/safeWriteJson" +import { safeWriteJson } from "../../utils/safeWriteJson" +import { isPublishCommittedError } from "../../services/file-safety/safeWriteText" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" From 99664d0a5fd7af4a6bd521dcff81e3448a19c830 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:24:00 +0800 Subject: [PATCH 60/60] test(file-safety): pin publish-failure rethrow, restore retry count, and staging no-ops Four review findings on this unit's own specs, each verified against the code at daee64d6fc88 before being changed: - DiffViewProvider.spec.ts: no test handed saveDirectly an ordinary publish failure, so a catch that swallowed every error would have passed the suite. The new case rejects with a plain Error and asserts the rejection propagates, the document is not opened, and no committed-write warning is logged. - safeWriteText.spec.ts line 649: the comment said the restore is retried once while the assertion checked greater than 0, which is the shape that lets a removed retry pass. Production retries a non-privilege restore failure exactly once, so the count is now asserted exactly at 2. - safeWriteText.spec.ts foreign-uid staging test: the comment claimed nothing is staged and nothing is published but no assertion backed it, so openSync, rename and rmdirSync are now asserted not to have been called. - safeWriteText.spec.ts parent-directory-creation block: its beforeEach hand-copied the shared _restoreFsDefaults helper, had already lost the fs.lstat default the helper sets and repeated two mocks the helper makes, while four sibling top-level suites call the helper. It now calls the helper and keeps only the two defaults specific to that block, and the describe title no longer cites safeWriteText.ts:288-290, which at this head is the doc comment of _restoreDaclWindows while the parent-directory mkdir and access live at 471-488. Negative controls, all run on this machine against the production code the specs pin: disabling the single restore retry turns the exact-count assertion red with expected 1 to be 2; widening the DiffViewProvider catch to swallow ordinary publish failures turns the new case red with promise resolved instead of rejecting; and the foreign-uid case, which is skipped on win32 because production reads process.getuid at safeWriteText.ts:192 and this host has none, was run once with the skip removed and getuid stubbed, where it passes, and once more with the ownership throw neutered, where it fails. Baseline over both spec files: 2 files passed, 183 passed and 1 skipped. ESLint over both changed files exits 0 with no findings, and eslint-suppressions.json is untouched. --- .../editor/__tests__/DiffViewProvider.spec.ts | 20 ++++++++++ .../__tests__/safeWriteText.spec.ts | 38 +++++++++---------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index c35387b5f9..0d366bfce8 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -914,6 +914,26 @@ describe("DiffViewProvider", () => { warn.mockRestore() }) + it("rethrows an ordinary publish failure instead of reporting a committed write", async () => { + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockClear() + vi.mocked(vscode.window.showTextDocument).mockClear() + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + // Only the committed-publish classes are tolerated here. An ordinary failure means the + // bytes never landed, so the tool layer must reject rather than record the save, and + // must not open a document for content that was never published. + const ordinary = new Error("EACCES: ordinary publish failure") + vi.mocked(safeWriteText).mockRejectedValueOnce(ordinary) + + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, true, 200)).rejects.toBe( + ordinary, + ) + + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("content committed with a warning")) + warn.mockRestore() + }) + beforeEach(() => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue(makeTextEditor()) diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts index 5b07bffb11..69456c95c7 100644 --- a/src/services/file-safety/__tests__/safeWriteText.spec.ts +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -642,11 +642,13 @@ describe("safeWriteText", () => { DaclRestoreError, ) expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) - // Save, then the restore attempt (icacls is retried once if it fails, so >= 2). + // One save, then the restore attempt and its single transient retry: production retries a + // non-privilege restore failure exactly once (see safeWriteText.ts step 5), so the count is + // pinned rather than bounded, and a removed retry has to fail here. const restoreCalls = vi.mocked(execFile).mock.calls.filter(function (call) { return Array.isArray(call[1]) && String(call[1]).includes("/restore") }) - expect(restoreCalls.length).toBeGreaterThan(0) + expect(restoreCalls.length).toBe(2) }) it("skips the DACL save and restore after a restore reports the missing privilege and still succeeds", async () => { @@ -1644,7 +1646,11 @@ describe("safeWriteText", () => { "UnsafeStagingDirectoryError", ) - // Nothing is staged and nothing is published from the foreign directory. + // Nothing is staged and nothing is published from the foreign directory, and the guard saw + // an existing directory, so it must not remove it either. + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + expect(fsSync.rmdirSync).not.toHaveBeenCalled() }, ) @@ -2012,25 +2018,17 @@ describe("resolvePublishTarget symlink cycle (S1)", () => { }) }) -describe("parent directory creation (safeWriteText.ts:288-290)", () => { +describe("parent directory creation", () => { beforeEach(() => { - // This block sits outside describe("safeWriteText"), so the resetAllMocks there - // never runs for it: without its own reset it inherits whatever the previous block - // left in the mocks, and a run with -t that selects only these tests gets bare - // vi.fn() stubs (undefined stats) and fails for unrelated reasons. - vi.resetAllMocks() - 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(fsSync.statSync).mockReturnValue(_stats(0o644)) - vi.mocked(fsSync.lstatSync).mockReturnValue(_dirStats()) - vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => - typeof args[3] === "number" ? args[3] : 0, - ) + // This block sits outside describe("safeWriteText"), so the resetAllMocks there never runs + // for it: without its own reset it inherits whatever the previous block left in the mocks, + // and a run with -t that selects only these tests gets bare vi.fn() stubs, undefined + // stats, and fails for unrelated reasons. The shared helper is the baseline the other + // top-level suites call, so a default added to it later reaches this block instead of + // drifting from it; the copy below had already lost the fs.lstat default it sets. + _restoreFsDefaults() + // Defaults this block needs that the shared helper does not carry. vi.mocked(fs.realpath).mockResolvedValue("/tmp/test-dir/target.txt") - vi.mocked(fs.mkdir).mockResolvedValue(undefined) - vi.mocked(fs.access).mockResolvedValue(undefined) vi.mocked(fsSync.openSync).mockReturnValue(1) })