From a37dd24f128e38aa4c4261d72ed580de73429fe2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 15:11:34 +0800 Subject: [PATCH 01/47] feat(file-safety): atomic text publish primitive + safeWriteJson refactor (A4, #1375) --- src/eslint-suppressions.json | 2 +- src/integrations/editor/DiffViewProvider.ts | 3 +- .../editor/__tests__/DiffViewProvider.spec.ts | 28 +- .../__tests__/safeWriteText.spec.ts | 614 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 307 +++++++++ src/utils/__tests__/safeWriteJson.test.ts | 87 ++- src/utils/safeWriteJson.ts | 108 +-- 7 files changed, 1040 insertions(+), 109 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/eslint-suppressions.json b/src/eslint-suppressions.json index 36cbfeac5b..77680449be 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1721,7 +1721,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 } }, "utils/tts.ts": { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..36f5323f19 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" @@ -1156,7 +1157,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 aee88f4061..511f0e7f3c 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -15,6 +15,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 @@ -26,6 +34,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 @@ -791,9 +801,9 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 2000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -814,9 +824,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() @@ -829,9 +839,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 + } +} diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 79d08678a0..064207e21f 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -312,9 +312,8 @@ describe("safeWriteJson", () => { expect(content).toEqual(newData) }) - // Test for console error suppression during backup deletion - test("should suppress console.error when backup deletion fails", async () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error + // Test for best-effort backup deletion (the backup lifecycle now lives in safeWriteText) + test("does not fail the write when backup deletion fails (orphaned backup is acceptable)", async () => { const initialData = { message: "Initial" } const newData = { message: "New" } @@ -322,18 +321,23 @@ describe("safeWriteJson", () => { // fs.unlink is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn vi.mocked(fs.unlink).mockImplementation(async (filePath: any) => { - if (filePath.toString().includes(".bak_")) { + if (filePath.toString().includes("safeWriteText.bak_")) { throw new Error("Backup deletion failed") } return fsPromisesActuals.unlink!(filePath) }) + // The write must still succeed: backup cleanup is best-effort inside + // safeWriteText and never masks the committed content. await safeWriteJson(currentTestFilePath, newData) - // Verify console.error was called with the expected message - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Successfully wrote"), expect.any(Error)) + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(newData) + + // The orphaned backup is still on disk because its deletion failed. + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) - consoleErrorSpy.mockRestore() vi.mocked(fs.unlink).mockRestore() }) @@ -434,9 +438,9 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // Test for rollback failure scenario - test("should log error and re-throw original if rollback fails", async () => { - const initialData = { message: "Initial, should be lost if rollback fails" } + // Test for rollback failure scenario (the rollback rename now lives in safeWriteText) + test("re-throws the original error when the rollback rename fails, leaving an orphaned backup", async () => { + const initialData = { message: "Initial, orphaned when rollback fails" } const newData = { message: "New content" } await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) @@ -451,20 +455,20 @@ describe("safeWriteJson", () => { // Second call: tempNewFilePath -> filePath (fail) throw new Error("Primary rename failed") } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, also fail) + // Third call: backup -> filePath (rollback, also fail) throw new Error("Rollback rename failed") } return fsPromisesActuals.rename!(oldPath, newPath) }) - // Should throw the original error, not the rollback error + // The original error must propagate, not the rollback error await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") - // Verify console.error was called for the rollback failure - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to restore backup"), - expect.objectContaining({ message: "Rollback rename failed" }), - ) + // The rollback failed inside safeWriteText, so the target is gone and + // the backup is orphaned on disk. + expect(await fileExists(currentTestFilePath)).toBe(false) + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) consoleErrorSpy.mockRestore() }) @@ -542,4 +546,53 @@ describe("safeWriteJson", () => { const content = await readFileContent(currentTestFilePath) expect(content).toEqual({ c: 3 }) }) + + // The commit rename targets the symlink referent. The staged temp file must + // therefore be created beside the RESOLVED target — staging beside the link + // would make the commit rename fail with EXDEV when the referent is on + // another filesystem. (Real symlinks are unavailable in this CI lane, so the + // resolution is simulated by mocking fs.realpath the same way.) + test("stages the temp file beside the symlink referent and commits onto it", async () => { + const referentDir = path.join(tempDir, "referent") + const linkDir = path.join(tempDir, "link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "test-file.json") + const referentPath = path.join(referentDir, "test-file.json") + // Seed the RESOLVED referent with real content (via the actual fs) so the + // write exercises replacement of an EXISTING referent: the lock is + // acquired on the caller path (realpath:false, which may be absent) while + // the backup + commit happen on the referent. + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) + + vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + + await safeWriteJson(callerPath, { after: true }) + + // the temp file was created next to the resolved referent, NOT beside the link + const tempPaths = vi.mocked(fsSyncActual.createWriteStream).mock.calls.map((call) => String(call[0])) + expect(tempPaths.some((p) => p.startsWith(referentDir + path.sep) && p.includes(".new_"))).toBe(true) + expect(tempPaths.some((p) => p.startsWith(linkDir + path.sep))).toBe(false) + + // the content was committed onto the referent + expect(await readFileContent(referentPath)).toEqual({ after: true }) + }) + + // CWE-732 regression: safeWriteJson stages the temp itself and passes it + // via tempPath, so safeWriteText must apply the existing target's mode to + // the staged temp before the atomic rename — otherwise a 0o600 target is + // published as 0o644. POSIX-only assertion (Windows ignores POSIX modes). + test.skipIf(process.platform === "win32")( + "preserves a restrictive 0o600 target mode through the atomic publish", + async () => { + await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify({ before: true })) + fsSyncActual.chmodSync(currentTestFilePath, 0o600) + + await safeWriteJson(currentTestFilePath, { after: true }) + + expect(fsSyncActual.statSync(currentTestFilePath).mode & 0o777).toBe(0o600) + expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) + }, + ) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 957a0bb20f..26af906b43 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,6 +4,8 @@ import * as path from "path" import * as lockfile from "proper-lockfile" import { JsonStreamStringify } from "json-stream-stringify" +import { resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../services/file-safety/safeWriteText" + /** * Options for safeWriteJson function */ @@ -31,7 +33,7 @@ export interface SafeWriteJsonOptions { * Safely writes JSON data to a file. * - Creates parent directories if they don't exist * - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path. - * - Writes to a temporary file first. + * - Writes to a temporary file first via JsonStreamStringify streaming. * - If the target file exists, it's backed up before being replaced. * - Attempts to roll back and clean up in case of errors. * - Supports pretty-printing with indentation while maintaining streaming efficiency. @@ -41,7 +43,6 @@ export interface SafeWriteJsonOptions { * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ - async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op @@ -51,10 +52,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Ensure directory structure exists with improved reliability try { - // Create directory with recursive option await fs.mkdir(dirPath, { recursive: true }) - - // Verify directory exists after creation attempt await fs.access(dirPath) } catch (dirError: any) { console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) @@ -84,13 +82,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // The releaseLock remains a no-op, so the finally block in the main file operations // try-catch-finally won't try to release an unacquired lock if this path is taken. console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) - // Propagate the lock acquisition error throw lockError } - // Variables to hold the actual paths of temp files if they are created. + // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null - let actualTempBackupFilePath: string | null = null try { // If a merge callback was provided, read the current file under the lock @@ -110,79 +106,43 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso data = options.merge(existing, data) } - // Step 1: Write data to a new temporary file. + // Step 1: Write data to a new temporary file via JSON streaming. + // Stage it beside the *resolved* target (the symlink referent when the path is + // a symlink): safeWriteText commits by renaming onto that referent, and a + // rename across filesystems would fail with EXDEV. + const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) actualTempNewFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, + path.dirname(resolvedTargetPath), + ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) - // Step 2: Check if the target file exists. If so, rename it to a backup path. - try { - // Check for target file existence - await fs.access(absoluteFilePath) - // Target exists, create a backup path and rename. - actualTempBackupFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, - ) - await fs.rename(absoluteFilePath, actualTempBackupFilePath) - } catch (accessError: any) { - // Explicitly type accessError - if (accessError.code !== "ENOENT") { - // An error other than "file not found" occurred during access check. - throw accessError - } - // Target file does not exist, so no backup is made. actualTempBackupFilePath remains null. + // Step 2: Delegate backup + commit + rollback to safeWriteText with the + // pre-written temp path. backup:true keeps the old safeWriteJson + // semantics (target -> backup before commit, rollback on failure) and + // keeps the target in place until safeWriteText captures its Windows + // DACL (safeWriteText dumps the DACL before its own backup rename and + // restores it onto the directory after the commit rename). + const textOptions: SafeWriteTextOptions = { + tempPath: actualTempNewFilePath, + backup: true, } - // Step 3: Rename the new temporary file to the target file path. - // This is the main "commit" step. - await fs.rename(actualTempNewFilePath, absoluteFilePath) + await safeWriteText(absoluteFilePath, "", textOptions) - // If we reach here, the new file is successfully in place. - // The original actualTempNewFilePath is now the main file, so we shouldn't try to clean it up as "temp". - // Mark as "used" or "committed" + // If we reach here, the new file is successfully in place and any + // backup has already been handled by safeWriteText. actualTempNewFilePath = null - - // Step 4: If a backup was created, attempt to delete it. - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - // Mark backup as handled - actualTempBackupFilePath = null - } catch (unlinkBackupError) { - // Log this error, but do not re-throw. The main operation was successful. - // actualTempBackupFilePath remains set, indicating an orphaned backup. - console.error( - `Successfully wrote ${absoluteFilePath}, but failed to clean up backup ${actualTempBackupFilePath}:`, - unlinkBackupError, - ) - } - } } catch (originalError) { console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath - const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath - - // Attempt rollback if a backup was made - if (backupFileToRollbackOrCleanupWithinCatch) { - try { - await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) - // Mark as handled, prevent later unlink of this path - actualTempBackupFilePath = null - } catch (rollbackError) { - // actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch - console.error( - `[Catch] Failed to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}:`, - rollbackError, - ) - } - } - // Cleanup the .new file if it exists + // A failed safeWriteText already rolled the backup (if any) back to + // the target path. Clean up the .new file if it still exists + // (safeWriteText also cleans up its tempPath on failure; this is a + // safety net in case its cleanup missed it). if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) @@ -194,26 +154,12 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } } - // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, - cleanupError, - ) - } - } throw originalError // This MUST be the error that rejects the promise. } finally { // Release the lock in the main finally block. try { - // releaseLock will be the actual unlock function if lock was acquired, - // or the initial no-op if acquisition failed. await releaseLock() } catch (unlockError) { - // Do not re-throw here, as the originalError from the try/catch (if any) is more important. console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) } } From 7fd49bceaa186f06096cfd5f5e4d0c3a656ddb9c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 30 Aug 2026 12:33:17 +0800 Subject: [PATCH 02/47] =?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 8f9cf8b9d9dbf0ab6848b22b1038e5045430465c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 15:43:03 +0800 Subject: [PATCH 03/47] 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 14d26dca4e7935d5f087763696345a009c0ce36c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 16:25:15 +0800 Subject: [PATCH 04/47] 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 df2694b0293872ee1d59d42f7b3e36e6d9f03613 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 16:27:31 +0800 Subject: [PATCH 05/47] test(utils): bring the safeWriteJson backup tests onto the copy semantics The tests assumed the backup was created by renaming the target away, so the call-count based rename mock no longer matched the flow once the backup became a copy. Updated: - the backup failure test now injects the failure at the copy, not at a rename; - the publish-failure tests now assert the target keeps its content because it was never moved; - the rollback test now asserts the target survives and the backup copy is removed, since there is no rollback rename. Also reset mock implementations between tests: a mockImplementation with a closure counter leaked into the following tests once the rename call count changed. Local run: 22 passed, 1 skipped; eslint clean with no suppression count change. --- src/utils/__tests__/safeWriteJson.test.ts | 68 ++++++++--------------- 1 file changed, 24 insertions(+), 44 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 064207e21f..65d8b27816 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -29,6 +29,7 @@ vi.mock("fs/promises", async () => { mockedFs.readFile = vi.fn(actual.readFile) as any mockedFs.rename = vi.fn(actual.rename) as any mockedFs.unlink = vi.fn(actual.unlink) as any + mockedFs.copyFile = vi.fn(actual.copyFile) mockedFs.access = vi.fn(actual.access) as any mockedFs.mkdtemp = vi.fn(actual.mkdtemp) as any mockedFs.rm = vi.fn(actual.rm) as any @@ -67,6 +68,9 @@ describe("safeWriteJson", () => { let currentTestFilePath: string beforeEach(async () => { + // Reset implementations between tests: a mockImplementation set by one test keeps its + // closure counter into the next test, so a call-count based mock leaks across tests. + vi.resetAllMocks() // Create a temporary directory for each test tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safeWriteJson-test-")) @@ -158,55 +162,39 @@ describe("safeWriteJson", () => { expect(content).toEqual({ initial: "content" }) }) - test("should handle failure when renaming filePath to tempBackupFilePath (filePath exists)", async () => { + test("should handle failure when copying the target to the backup (filePath exists)", async () => { const initialData = { message: "Initial content, should remain" } const newData = { message: "New content, should not be written" } // Overwrite the pre-created file with specific initial data await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) - // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - vi.mocked(fs.rename).mockImplementationOnce(async () => { - throw new Error("Rename to backup failed") + // fs.copyFile is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn + vi.mocked(fs.copyFile).mockImplementationOnce(async () => { + throw new Error("Copy to backup failed") }) - await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Rename to backup failed") + await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Copy to backup failed") // Verify the original file still exists with initial content const content = await readFileContent(currentTestFilePath) expect(content).toEqual(initialData) }) - test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup succeeded)", async () => { + test("a failed publish leaves the target in place because the backup is a copy", async () => { const initialData = { message: "Initial content, should be restored" } const newData = { message: "New content" } // Overwrite the pre-created file with specific initial data await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) - // Track rename calls - let renameCallCount = 0 - - // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { - renameCallCount++ - if (renameCallCount === 1) { - // First call: filePath -> tempBackupFilePath (should succeed) - return fsPromisesActuals.rename!(oldPath, newPath) - } else if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (should fail) - throw new Error("Rename from temp to final failed") - } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, should succeed) - return fsPromisesActuals.rename!(oldPath, newPath) - } - // Default: use original implementation - return fsPromisesActuals.rename!(oldPath, newPath) + // The backup is a copy, so the only rename is the publish. + vi.mocked(fs.rename).mockImplementationOnce(async () => { + throw new Error("Rename from temp to final failed") }) - await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Rename from temp to final failed") - // Verify the file was restored to initial content + // The target was never moved, so it still holds the initial content. const content = await readFileContent(currentTestFilePath) expect(content).toEqual(initialData) }) @@ -353,8 +341,8 @@ describe("safeWriteJson", () => { let renameCallCount = 0 vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { renameCallCount++ - if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (should fail) + if (renameCallCount === 1) { + // The only rename is the publish (temp -> filePath). throw new Error("Rename failed") } // For all other calls, use the original implementation @@ -439,7 +427,7 @@ describe("safeWriteJson", () => { }) // Test for rollback failure scenario (the rollback rename now lives in safeWriteText) - test("re-throws the original error when the rollback rename fails, leaving an orphaned backup", async () => { + test("propagates the publish failure and removes the backup copy", async () => { const initialData = { message: "Initial, orphaned when rollback fails" } const newData = { message: "New content" } @@ -448,27 +436,19 @@ describe("safeWriteJson", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - let renameCallCount = 0 - vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { - renameCallCount++ - if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (fail) - throw new Error("Primary rename failed") - } else if (renameCallCount === 3) { - // Third call: backup -> filePath (rollback, also fail) - throw new Error("Rollback rename failed") - } - return fsPromisesActuals.rename!(oldPath, newPath) + // The backup is a copy, so the only rename is the publish. + vi.mocked(fs.rename).mockImplementationOnce(async () => { + throw new Error("Primary rename failed") }) // The original error must propagate, not the rollback error await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") - // The rollback failed inside safeWriteText, so the target is gone and - // the backup is orphaned on disk. - expect(await fileExists(currentTestFilePath)).toBe(false) + // The target was never moved, so it survives the failed publish, and the backup copy is + // removed by the failure cleanup. + expect(await fileExists(currentTestFilePath)).toBe(true) const entries = await fs.readdir(tempDir) - expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(false) consoleErrorSpy.mockRestore() }) From db3cc26aa0d094ae1056fe4ff2c1200e35e21a01 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 19:09:48 +0800 Subject: [PATCH 06/47] 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 34b37ae064149f08ea4b6116c250270e45db8b04 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 19:19:27 +0800 Subject: [PATCH 07/47] 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 d3aec7c3d3663b5257b7a069ad0d4dcf8e6fec80 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 19:42:54 +0800 Subject: [PATCH 08/47] 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 8b838f372b442ecbe100806b27776528e439c7df Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 00:39:40 +0800 Subject: [PATCH 09/47] 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. --- src/eslint-suppressions.json | 3462 ++++++++--------- .../__tests__/safeWriteText.spec.ts | 151 +- src/services/file-safety/safeWriteText.ts | 137 +- src/utils/__tests__/safeWriteJson.test.ts | 51 +- src/utils/safeWriteJson.ts | 22 +- 5 files changed, 2024 insertions(+), 1799 deletions(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index ff80ef4ca5..e4bb0e5463 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1,1732 +1,1732 @@ { - "__mocks__/fs/promises.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/ClineProvider.delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "__tests__/ClineProvider.history-resume-delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 70 - } - }, - "__tests__/abandonSubtask.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "__tests__/api-subtask.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/delegation-concurrent.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "__tests__/delegation-events.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "__tests__/migrateSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/nested-delegation-resume.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "__tests__/new-task-delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "activate/CodeActionProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "activate/__tests__/CodeActionProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "activate/__tests__/handleUri.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "activate/__tests__/registerCommands.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "activate/registerCodeActions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "activate/registerCommands.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "activate/registerTerminalActions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/anthropic-vertex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 31 - } - }, - "api/providers/__tests__/anthropic.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/base-openai-compatible-provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/base-provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/__tests__/bedrock-custom-arn.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "api/providers/__tests__/bedrock-error-handling.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/providers/__tests__/bedrock-inference-profiles.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "api/providers/__tests__/bedrock-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "api/providers/__tests__/bedrock-reasoning.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/bedrock.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 37 - } - }, - "api/providers/__tests__/deepseek.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "api/providers/__tests__/gemini-handler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "api/providers/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 33 - } - }, - "api/providers/__tests__/kenari.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/kimi-code.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/lite-llm.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 36 - } - }, - "api/providers/__tests__/lm-studio-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/__tests__/mimo.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "api/providers/__tests__/minimax.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/moonshot.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "api/providers/__tests__/native-ollama.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "api/providers/__tests__/openai-codex-native-tool-calls.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "api/providers/__tests__/openai-codex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "api/providers/__tests__/openai-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "api/providers/__tests__/openai-native-usage.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "api/providers/__tests__/openai-native.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 74 - } - }, - "api/providers/__tests__/openai-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/openai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/__tests__/opencode-go.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "api/providers/__tests__/poe.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/qwen-code-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/sambanova.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/unbound.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/vertex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/zai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/anthropic-vertex.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/anthropic.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/base-openai-compatible-provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/providers/base-provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/bedrock.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "api/providers/deepseek.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/fetchers/__tests__/kenari.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/kimi-code.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/lmstudio.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/fetchers/__tests__/moonshot.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/ollama.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/fetchers/__tests__/opencode-go.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/providers/fetchers/litellm.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/gemini.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/lite-llm.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/providers/lm-studio.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/mimo.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/moonshot.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/native-ollama.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/openai-codex.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 31 - } - }, - "api/providers/openai-native.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 28 - } - }, - "api/providers/openai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/openrouter.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/providers/poe.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/qwen-code.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/requesty.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/unbound.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/utils/__tests__/error-handler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "api/providers/utils/__tests__/image-generation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "api/providers/utils/__tests__/timeout-config.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/utils/error-handler.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/providers/xai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/transform/__tests__/ai-sdk.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/anthropic-filter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/transform/__tests__/bedrock-converse-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/gemini-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/mistral-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/__tests__/model-params.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/__tests__/openai-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 49 - } - }, - "api/transform/__tests__/r1-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/__tests__/reasoning.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/__tests__/responses-api-input.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/__tests__/responses-api-stream.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/zai-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/transform/ai-sdk.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/bedrock-converse-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/cache-strategy/__tests__/cache-strategy.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "api/transform/caching/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/gemini-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/openai-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/r1-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/responses-api-input.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/transform/responses-api-stream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/transform/zai-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/assistant-message/NativeToolCallParser.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/assistant-message/presentAssistantMessage.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/checkpoints/__tests__/checkpoint.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/checkpoints/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/condense/__tests__/condense.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/condense/__tests__/foldedFileContext.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "core/condense/__tests__/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 24 - } - }, - "core/config/ContextProxy.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/config/CustomModesManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/ProviderSettingsManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/config/__tests__/ContextProxy.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/__tests__/CustomModesManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/config/__tests__/ProviderSettingsManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/context-management/__tests__/context-management.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/context-tracking/__tests__/FileContextTracker.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/context/context-management/__tests__/context-error-handling.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/context/context-management/context-error-handling.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/diff/stats.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/environment/__tests__/getEnvironmentDetails.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/ignore/__tests__/RooIgnoreController.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/mentions/__tests__/processUserContentMentions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/mentions/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/mentions/processUserContentMentions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/message-manager/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "core/message-manager/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/prompts/__tests__/add-custom-instructions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/__tests__/get-prompt-component.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/__tests__/responses-rooignore.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "core/prompts/__tests__/system-prompt.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/prompts/sections/__tests__/custom-instructions-global.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "core/prompts/sections/__tests__/custom-instructions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 55 - } - }, - "core/prompts/sections/__tests__/system-info.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "core/prompts/tools/filter-tools-for-mode.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/tools/native-tools/__tests__/read_file.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/task-persistence/__tests__/importRooTaskHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/task-persistence/__tests__/taskMessages.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/task-persistence/apiMessages.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/task/Task.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "core/task/__tests__/Task.dispose.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/Task.persistence.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/task/__tests__/Task.sticky-profile-race.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/Task.throttle.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 24 - } - }, - "core/task/__tests__/apiConversationHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, - "core/task/__tests__/ask-clear-approval-buttons.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "core/task/__tests__/ask-queued-message-drain.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "core/task/__tests__/grace-retry-errors.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/task/__tests__/grounding-sources.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/task/__tests__/native-tools-filtering.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/new-task-isolation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/task/__tests__/reasoning-preservation.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "core/task/__tests__/task-tool-history.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/apiConversationHistory.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/tools/BaseTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/CodebaseSearchTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/GenerateImageTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/NewTaskTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/ReadFileTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/tools/ToolRepetitionDetector.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/UpdateTodoListTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/UseMcpToolTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/ReadCommandOutputTool.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "core/tools/__tests__/attemptCompletionTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 11 - } - }, - "core/tools/__tests__/editFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/tools/__tests__/editTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/__tests__/executeCommand.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/tools/__tests__/executeCommandTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/tools/__tests__/generateImageTool.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "core/tools/__tests__/listFilesTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "core/tools/__tests__/mcpServerRestriction.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "core/tools/__tests__/newTaskTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 26 - } - }, - "core/tools/__tests__/readFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 98 - } - }, - "core/tools/__tests__/runSlashCommandTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/searchReplaceTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/skillTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/updateTodoListTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/useMcpToolTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "core/tools/__tests__/validateToolUse.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/writeToFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/tools/helpers/toolResultFormatting.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/validateToolUse.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/webview/ClineProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "core/webview/__tests__/ClineProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 196 - } - }, - "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/webview/__tests__/ClineProvider.taskHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/webview/__tests__/checkpointRestoreHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/diagnosticsHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "core/webview/__tests__/messageEnhancer.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/webview/__tests__/skillsMessageHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/telemetrySettingsTracking.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/webviewMessageHandler.delete.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "core/webview/__tests__/webviewMessageHandler.edit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 13 - } - }, - "core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 56 - } - }, - "core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/webviewMessageHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "core/webview/messageEnhancer.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/webviewMessageHandler.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "extension/__tests__/api-delete-queued-message.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "extension/__tests__/api-send-message.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "extension/api.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "i18n/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "i18n/setup.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/editor/DiffViewProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/editor/__tests__/DiffViewProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 310 - } - }, - "integrations/editor/__tests__/EditorUtils.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "integrations/kimi-code/__tests__/oauth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/misc/__tests__/export-markdown.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/misc/__tests__/extract-text.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "integrations/misc/__tests__/line-counter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/misc/__tests__/open-file.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 11 - } - }, - "integrations/misc/__tests__/performance/processCarriageReturns.benchmark.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "integrations/terminal/__tests__/OutputInterceptor.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "integrations/terminal/__tests__/TerminalProcess.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "integrations/terminal/__tests__/TerminalProcess.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/TerminalProcessInterpretExitCode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "integrations/terminal/__tests__/TerminalProfile.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "integrations/terminal/__tests__/TerminalRegistry.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "integrations/terminal/__tests__/setupTerminalTests.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/bashStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/cmdStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/streamUtils/pwshStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/theme/getTheme.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "services/__tests__/zoo-code-auth.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/checkpoints/__tests__/ShadowCheckpointService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/__tests__/config-manager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/__tests__/manager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 84 - } - }, - "services/code-index/__tests__/orchestrator.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, - "services/code-index/__tests__/service-factory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 43 - } - }, - "services/code-index/embedders/__tests__/bedrock.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "services/code-index/embedders/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/mistral.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/ollama.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 15 - } - }, - "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 28 - } - }, - "services/code-index/embedders/__tests__/openai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "services/code-index/embedders/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/bedrock.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/embedders/ollama.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/openai-compatible.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/openai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/openrouter.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/interfaces/vector-store.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/orchestrator.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/processors/__tests__/file-watcher.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "services/code-index/processors/__tests__/parser.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "services/code-index/processors/__tests__/scanner.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 26 - } - }, - "services/code-index/processors/file-watcher.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/processors/scanner.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/semble/__tests__/provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "services/code-index/semble/__tests__/semble-cli.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/semble/__tests__/semble-downloader.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 61 - } - }, - "services/code-index/semble/provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/semble/semble-cli.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/shared/__tests__/validation-helpers.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/shared/validation-helpers.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 87 - } - }, - "services/code-index/vector-store/qdrant-client.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/glob/__tests__/gitignore-integration.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/glob/__tests__/gitignore-test.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/glob/__tests__/list-files-limit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "services/glob/__tests__/list-files.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "services/marketplace/MarketplaceManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/marketplace/SimpleInstaller.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "services/marketplace/__tests__/MarketplaceManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/marketplace/__tests__/SimpleInstaller.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "services/marketplace/__tests__/marketplace-setting-check.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/mcp/McpHub.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "services/mcp/McpOAuthClientProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/mcp/McpServerManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/mcp/__tests__/McpHub.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 150 - } - }, - "services/mcp/__tests__/McpOAuthClientProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "services/mcp/__tests__/SecretStorageService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/mcp/utils/__tests__/callbackServer.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/mcp/utils/__tests__/oauth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "services/mcp/utils/callbackServer.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/mcp/utils/oauth.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/mdm/__tests__/MdmService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/ripgrep/__tests__/diagnostic.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/roo-config/__tests__/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "services/roo-config/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/rules/__tests__/rules.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/search/__tests__/file-search.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/skills/__tests__/SkillsManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/tree-sitter/__tests__/helpers.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/tree-sitter/__tests__/markdownParser.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/__tests__/api.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "shared/__tests__/embeddingModels.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/__tests__/modes-empty-prompt-component.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/api.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/checkExistApiConfig.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/cost.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/parse-command.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/support-prompt.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "shared/tools.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/utils/__tests__/requesty.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/__tests__/autoImportSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "utils/__tests__/enhance-prompt.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "utils/__tests__/git.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 95 - } - }, - "utils/__tests__/json-schema.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "utils/__tests__/migrateSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "utils/__tests__/outputChannelLogger.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "utils/__tests__/safeWriteJson.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "utils/__tests__/shell.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "utils/__tests__/storage.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "utils/__tests__/tiktoken.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/config.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/export.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/safeWriteJson.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "utils/tts.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "vitest.setup.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - } -} + "__mocks__/fs/promises.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/ClineProvider.delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "__tests__/ClineProvider.history-resume-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 70 + } + }, + "__tests__/abandonSubtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "__tests__/api-subtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/delegation-concurrent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/delegation-events.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/migrateSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/nested-delegation-resume.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "__tests__/new-task-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "activate/CodeActionProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/__tests__/CodeActionProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "activate/__tests__/handleUri.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "activate/__tests__/registerCommands.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCodeActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCommands.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/registerTerminalActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/anthropic-vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/__tests__/anthropic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/base-openai-compatible-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/base-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/__tests__/bedrock-custom-arn.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/bedrock-error-handling.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/__tests__/bedrock-inference-profiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "api/providers/__tests__/bedrock-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "api/providers/__tests__/bedrock-reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 37 + } + }, + "api/providers/__tests__/deepseek.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/gemini-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "api/providers/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 33 + } + }, + "api/providers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/lite-llm.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 36 + } + }, + "api/providers/__tests__/lm-studio-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/mimo.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "api/providers/__tests__/minimax.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "api/providers/__tests__/native-ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "api/providers/__tests__/openai-codex-native-tool-calls.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "api/providers/__tests__/openai-codex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "api/providers/__tests__/openai-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "api/providers/__tests__/openai-native-usage.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "api/providers/__tests__/openai-native.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 74 + } + }, + "api/providers/__tests__/openai-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/__tests__/poe.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/qwen-code-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/sambanova.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/unbound.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/zai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/anthropic-vertex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/anthropic.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/base-openai-compatible-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/base-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/deepseek.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/lmstudio.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/ollama.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/fetchers/litellm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/gemini.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/lite-llm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/lm-studio.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/mimo.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/moonshot.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/native-ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openai-codex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/openai-native.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 28 + } + }, + "api/providers/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/poe.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/qwen-code.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/requesty.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/unbound.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/utils/__tests__/error-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "api/providers/utils/__tests__/image-generation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/providers/utils/__tests__/timeout-config.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/utils/error-handler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/xai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/transform/__tests__/ai-sdk.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/anthropic-filter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/__tests__/bedrock-converse-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/gemini-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/mistral-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/model-params.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/openai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 49 + } + }, + "api/transform/__tests__/r1-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/__tests__/responses-api-input.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/responses-api-stream.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/zai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/transform/ai-sdk.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/bedrock-converse-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/cache-strategy/__tests__/cache-strategy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/transform/caching/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/gemini-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/openai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/r1-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/responses-api-input.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/responses-api-stream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/transform/zai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/assistant-message/NativeToolCallParser.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/assistant-message/presentAssistantMessage.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/checkpoints/__tests__/checkpoint.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/checkpoints/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/condense/__tests__/condense.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/condense/__tests__/foldedFileContext.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/condense/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/config/ContextProxy.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/CustomModesManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/ProviderSettingsManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/config/__tests__/ContextProxy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/__tests__/ProviderSettingsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/context-management/__tests__/context-management.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context-tracking/__tests__/FileContextTracker.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/context/context-management/__tests__/context-error-handling.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context/context-management/context-error-handling.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/diff/stats.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/environment/__tests__/getEnvironmentDetails.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/ignore/__tests__/RooIgnoreController.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/__tests__/processUserContentMentions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/mentions/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/processUserContentMentions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/message-manager/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/message-manager/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/prompts/__tests__/add-custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/get-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/responses-rooignore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/prompts/__tests__/system-prompt.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/prompts/sections/__tests__/custom-instructions-global.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "core/prompts/sections/__tests__/custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 55 + } + }, + "core/prompts/sections/__tests__/system-info.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/prompts/tools/filter-tools-for-mode.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/native-tools/__tests__/read_file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/importRooTaskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task-persistence/__tests__/taskMessages.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task-persistence/apiMessages.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task/Task.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "core/task/__tests__/Task.dispose.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.persistence.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/task/__tests__/Task.sticky-profile-race.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.throttle.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/task/__tests__/apiConversationHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "core/task/__tests__/ask-clear-approval-buttons.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "core/task/__tests__/ask-queued-message-drain.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/task/__tests__/grace-retry-errors.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/grounding-sources.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/native-tools-filtering.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/new-task-isolation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task/__tests__/reasoning-preservation.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/task/__tests__/task-tool-history.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/apiConversationHistory.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/BaseTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/CodebaseSearchTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/GenerateImageTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/NewTaskTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/ReadFileTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/tools/ToolRepetitionDetector.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UpdateTodoListTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UseMcpToolTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/ReadCommandOutputTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/tools/__tests__/attemptCompletionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "core/tools/__tests__/editFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/__tests__/editTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/__tests__/executeCommand.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/tools/__tests__/executeCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/__tests__/generateImageTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/tools/__tests__/listFilesTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/tools/__tests__/mcpServerRestriction.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/tools/__tests__/newTaskTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "core/tools/__tests__/readFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 98 + } + }, + "core/tools/__tests__/runSlashCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/searchReplaceTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/skillTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/updateTodoListTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/useMcpToolTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/tools/__tests__/validateToolUse.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/writeToFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/helpers/toolResultFormatting.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/validateToolUse.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/webview/ClineProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "core/webview/__tests__/ClineProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 196 + } + }, + "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/webview/__tests__/ClineProvider.taskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/webview/__tests__/checkpointRestoreHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/diagnosticsHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/webview/__tests__/messageEnhancer.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/webview/__tests__/skillsMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/telemetrySettingsTracking.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.delete.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/webview/__tests__/webviewMessageHandler.edit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 13 + } + }, + "core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 56 + } + }, + "core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "core/webview/messageEnhancer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/webviewMessageHandler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "extension/__tests__/api-delete-queued-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "extension/__tests__/api-send-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "extension/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "i18n/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "i18n/setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/DiffViewProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/__tests__/DiffViewProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 310 + } + }, + "integrations/editor/__tests__/EditorUtils.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/kimi-code/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/export-markdown.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/extract-text.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "integrations/misc/__tests__/line-counter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/misc/__tests__/open-file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "integrations/misc/__tests__/performance/processCarriageReturns.benchmark.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "integrations/terminal/__tests__/OutputInterceptor.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "integrations/terminal/__tests__/TerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "integrations/terminal/__tests__/TerminalProcess.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessInterpretExitCode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/terminal/__tests__/TerminalProfile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "integrations/terminal/__tests__/TerminalRegistry.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "integrations/terminal/__tests__/setupTerminalTests.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/bashStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/cmdStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/streamUtils/pwshStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/theme/getTheme.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/__tests__/zoo-code-auth.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/checkpoints/__tests__/ShadowCheckpointService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/config-manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 84 + } + }, + "services/code-index/__tests__/orchestrator.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "services/code-index/__tests__/service-factory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 43 + } + }, + "services/code-index/embedders/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/code-index/embedders/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/mistral.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 15 + } + }, + "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 28 + } + }, + "services/code-index/embedders/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/code-index/embedders/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/embedders/ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/openai-compatible.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/interfaces/vector-store.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/orchestrator.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/__tests__/file-watcher.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "services/code-index/processors/__tests__/parser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/code-index/processors/__tests__/scanner.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "services/code-index/processors/file-watcher.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/scanner.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/__tests__/provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "services/code-index/semble/__tests__/semble-cli.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/semble/__tests__/semble-downloader.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 61 + } + }, + "services/code-index/semble/provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/semble-cli.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/shared/__tests__/validation-helpers.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/shared/validation-helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 87 + } + }, + "services/code-index/vector-store/qdrant-client.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/glob/__tests__/gitignore-integration.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/glob/__tests__/gitignore-test.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/glob/__tests__/list-files-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "services/glob/__tests__/list-files.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/marketplace/MarketplaceManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/marketplace/SimpleInstaller.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/marketplace/__tests__/MarketplaceManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/marketplace/__tests__/SimpleInstaller.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "services/marketplace/__tests__/marketplace-setting-check.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/McpHub.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/mcp/McpOAuthClientProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mcp/McpServerManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/__tests__/McpHub.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 150 + } + }, + "services/mcp/__tests__/McpOAuthClientProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/mcp/__tests__/SecretStorageService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/utils/__tests__/callbackServer.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/mcp/utils/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/mcp/utils/callbackServer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/utils/oauth.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mdm/__tests__/MdmService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/ripgrep/__tests__/diagnostic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/roo-config/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/roo-config/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/rules/__tests__/rules.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/search/__tests__/file-search.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/skills/__tests__/SkillsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/tree-sitter/__tests__/helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/tree-sitter/__tests__/markdownParser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/__tests__/api.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "shared/__tests__/embeddingModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/__tests__/modes-empty-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/checkExistApiConfig.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/cost.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/parse-command.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/support-prompt.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "shared/tools.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/utils/__tests__/requesty.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/__tests__/autoImportSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "utils/__tests__/enhance-prompt.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "utils/__tests__/git.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 95 + } + }, + "utils/__tests__/json-schema.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "utils/__tests__/migrateSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "utils/__tests__/outputChannelLogger.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "utils/__tests__/safeWriteJson.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "utils/__tests__/shell.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "utils/__tests__/storage.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "utils/__tests__/tiktoken.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/config.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/export.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/safeWriteJson.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "utils/tts.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "vitest.setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + } +} \ 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 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) + } } diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 65d8b27816..9155b3e3a1 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -162,23 +162,21 @@ describe("safeWriteJson", () => { expect(content).toEqual({ initial: "content" }) }) - test("should handle failure when copying the target to the backup (filePath exists)", async () => { - const initialData = { message: "Initial content, should remain" } - const newData = { message: "New content, should not be written" } + test("does not copy the target before the commit: the rename is already atomic", async () => { + const initialData = { message: "Initial content" } + const newData = { message: "New content" } - // Overwrite the pre-created file with specific initial data await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) - // fs.copyFile is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - vi.mocked(fs.copyFile).mockImplementationOnce(async () => { - throw new Error("Copy to backup failed") - }) + await safeWriteJson(currentTestFilePath, newData) - await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Copy to backup failed") + // A backup copy would be a full extra read+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() - // Verify the original file still exists with initial content const content = await readFileContent(currentTestFilePath) - expect(content).toEqual(initialData) + expect(content).toEqual(newData) }) test("a failed publish leaves the target in place because the backup is a copy", async () => { @@ -300,34 +298,9 @@ describe("safeWriteJson", () => { expect(content).toEqual(newData) }) - // Test for best-effort backup deletion (the backup lifecycle now lives in safeWriteText) - test("does not fail the write when backup deletion fails (orphaned backup is acceptable)", async () => { - const initialData = { message: "Initial" } - const newData = { message: "New" } - - await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) - - // fs.unlink is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - vi.mocked(fs.unlink).mockImplementation(async (filePath: any) => { - if (filePath.toString().includes("safeWriteText.bak_")) { - throw new Error("Backup deletion failed") - } - return fsPromisesActuals.unlink!(filePath) - }) - - // The write must still succeed: backup cleanup is best-effort inside - // safeWriteText and never masks the committed content. - await safeWriteJson(currentTestFilePath, newData) - - const content = await readFileContent(currentTestFilePath) - expect(content).toEqual(newData) - - // The orphaned backup is still on disk because its deletion failed. - const entries = await fs.readdir(tempDir) - expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) - - vi.mocked(fs.unlink).mockRestore() - }) + // Removed with the backup option: safeWriteJson no longer takes a backup copy, + // so there is no orphaned backup to tolerate here. The equivalent coverage of + // best-effort backup cleanup lives in safeWriteText.spec.ts. // The expected error message might need to change if the mock behaves differently. test("should handle failure when renaming tempNewFilePath to filePath (filePath initially exists)", async () => { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 61fafc369f..ad9436a155 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -105,31 +105,27 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) - // Step 2: Delegate backup + commit + rollback to safeWriteText with the - // pre-written temp path. backup:true keeps the old safeWriteJson - // semantics (target -> backup before commit, rollback on failure) and - // keeps the target in place until safeWriteText captures its Windows - // DACL (safeWriteText dumps the DACL before its own backup rename and - // restores it onto the directory after the commit rename). + // Step 2: Delegate the atomic commit to safeWriteText with the pre-written + // temp path. The publish is a single rename, so the target stays intact on + // failure and no backup copy is needed. safeWriteText still captures the + // Windows DACL before the commit rename and restores it afterwards. const textOptions: SafeWriteTextOptions = { tempPath: actualTempNewFilePath, - backup: true, } await safeWriteText(absoluteFilePath, "", textOptions) - // If we reach here, the new file is successfully in place and any - // backup has already been handled by safeWriteText. + // If we reach here, the new file is successfully in place. actualTempNewFilePath = null } catch (originalError) { console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath - // A failed safeWriteText already rolled the backup (if any) back to - // the target path. Clean up the .new file if it still exists - // (safeWriteText also cleans up its tempPath on failure; this is a - // safety net in case its cleanup missed it). + // A failed safeWriteText leaves the target untouched (the commit rename never + // landed). Clean up the .new file if it still exists (safeWriteText also + // cleans up its tempPath on failure; this is a safety net in case its + // cleanup missed it). if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) From e7843fbf76f24130b91717a16dce9648a52d5bad Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 00:55:28 +0800 Subject: [PATCH 10/47] 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. --- .../__tests__/safeWriteText.spec.ts | 16 +++++++++++++++- src/utils/__tests__/safeWriteJson.test.ts | 19 +++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) 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"), + ]) }, ) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 9155b3e3a1..299c73c31b 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -399,9 +399,10 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // Test for rollback failure scenario (the rollback rename now lives in safeWriteText) - test("propagates the publish failure and removes the backup copy", async () => { - const initialData = { message: "Initial, orphaned when rollback fails" } + // The publish is a single rename: a failed commit must leave the target intact + // and must not leak the staged file. + test("propagates the publish failure and removes the staged .new_ file", async () => { + const initialData = { message: "Initial, must survive a failed publish" } const newData = { message: "New content" } await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) @@ -409,19 +410,21 @@ describe("safeWriteJson", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - // The backup is a copy, so the only rename is the publish. + // The only rename is the publish, so failing it is the failed-publish case. vi.mocked(fs.rename).mockImplementationOnce(async () => { throw new Error("Primary rename failed") }) - // The original error must propagate, not the rollback error + // The original error must propagate, not the cleanup error await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") - // The target was never moved, so it survives the failed publish, and the backup copy is - // removed by the failure cleanup. expect(await fileExists(currentTestFilePath)).toBe(true) + + // This is the caller-supplied staged path, so it is the file that leaks when the + // caller's cleanup misses it; safeWriteText's own failure test only covers the + // temp path it generates itself. const entries = await fs.readdir(tempDir) - expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(false) + expect(entries.some((entry) => entry.includes(".new_"))).toBe(false) consoleErrorSpy.mockRestore() }) From 8e37bc18a4544c33ad15eeb5ef626a9add9b2211 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 01:10:06 +0800 Subject: [PATCH 11/47] 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 99fee2cc0db891fdb59f67d931f3c8a14f3b4eba Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 05:34:47 +0800 Subject: [PATCH 12/47] 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 +++++++++++++++++++++++ src/utils/safeWriteJson.ts | 25 ++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 299c73c31b..4ecf8241e8 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -106,6 +106,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. diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index ad9436a155..c54bf4362f 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -103,7 +103,19 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) - await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) + // The staged file holds the entire new content while it exists, so it must not + // be created with the process default (0o666 & ~umask, i.e. 0o644) next to a + // target that is deliberately narrower - a 0o600 settings file in a shared + // directory, for example. Match the existing target's mode; a new target keeps + // the ordinary default. + let stagingMode: number | undefined + try { + stagingMode = fsSync.statSync(resolvedTargetPath).mode & 0o777 + } catch { + stagingMode = undefined + } + + await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint, stagingMode) // Step 2: Delegate the atomic commit to safeWriteText with the pre-written // temp path. The publish is a single rename, so the target stays intact on @@ -155,9 +167,16 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso * @param prettyPrint Whether to format the JSON with indentation. * @returns Promise */ -async function _streamDataToFile(targetPath: string, data: any, prettyPrint = false): Promise { +async function _streamDataToFile( + targetPath: string, + data: any, + prettyPrint = false, + mode?: number, +): Promise { // Stream data to avoid high memory usage for large JSON objects. - const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8" }) + // mode is explicit because createWriteStream defaults to 0o666 (& ~umask): the + // staged file is readable by others until the commit renames it onto the target. + const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8", ...(mode !== undefined ? { mode } : {}) }) // JsonStreamStringify traverses the object and streams tokens directly // The 'spaces' parameter adds indentation during streaming, not via a separate pass From febe20d03024520d47369b37628e87d7901e90a9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:04:04 +0800 Subject: [PATCH 13/47] 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 +++++++++++++++++++++++ src/utils/safeWriteJson.ts | 21 ++++++--- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 4ecf8241e8..b080669aed 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -147,6 +147,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. diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index c54bf4362f..7f60642b15 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -103,15 +103,26 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) - // The staged file holds the entire new content while it exists, so it must not + // The staged file holds the entire new content while it exists, so it must not // be created with the process default (0o666 & ~umask, i.e. 0o644) next to a // target that is deliberately narrower - a 0o600 settings file in a shared - // directory, for example. Match the existing target's mode; a new target keeps - // the ordinary default. + // directory, for example. Mirror the existing target's mode, but always keep the + // owner read/write bits: safeWriteText reopens the staged file with "r+" before it + // applies the target mode with fchmod, so a read-only mirror (0o400/0o444) would + // fail that open with EACCES. A target that does not exist yet keeps the ordinary + // default; any other stat failure is surfaced instead of silently widening the + // creation mode. let stagingMode: number | undefined try { - stagingMode = fsSync.statSync(resolvedTargetPath).mode & 0o777 - } catch { + stagingMode = (fsSync.statSync(resolvedTargetPath).mode & 0o777) | 0o600 + } catch (statError: unknown) { + const statCode = + typeof statError === "object" && statError !== null && "code" in statError + ? (statError as { code?: string }).code + : undefined + if (statCode !== "ENOENT") { + throw statError + } stagingMode = undefined } From e367bdbb889463dac3020db8ba390447aeebc1a8 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:22:24 +0800 Subject: [PATCH 14/47] 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 +++++++++++++++++++ src/utils/safeWriteJson.ts | 27 +++++++----- 3 files changed, 75 insertions(+), 11 deletions(-) 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 b080669aed..601d9c9eb9 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -199,6 +199,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. diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 7f60642b15..dd575917d4 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -50,8 +50,16 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op + // Resolve the publish target (the symlink referent when the path is a symlink) + // BEFORE the lock is taken. The lock, the merge read, the staged file and the + // commit rename must all key off this one canonical path: if the lock is keyed on + // the caller's alias while the publish lands on the referent, two writers reaching + // the same file through different names (the link and its referent) serialize on + // different locks and silently lose each other's merged updates. + const canonicalPath = await resolvePublishTarget(absoluteFilePath) + // For directory creation - const dirPath = path.dirname(absoluteFilePath) + const dirPath = path.dirname(canonicalPath) // Ensure directory structure exists with improved reliability try { @@ -70,7 +78,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // remains a no-op, so the finally block in the main file operations // try-catch-finally won't try to release an unacquired lock if this // path is taken. - releaseLock = await acquireFileLock(absoluteFilePath) + releaseLock = await acquireFileLock(canonicalPath) // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null @@ -82,7 +90,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso if (options?.merge) { let existing: unknown = null try { - existing = JSON.parse(await fs.readFile(absoluteFilePath, "utf8")) + existing = JSON.parse(await fs.readFile(canonicalPath, "utf8")) } catch (error: unknown) { const code = error && typeof error === "object" && "code" in error ? (error as { code: string }).code : undefined @@ -93,13 +101,10 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso data = options.merge(existing, data) } - // Step 1: Write data to a new temporary file via JSON streaming. - // Stage it beside the *resolved* target (the symlink referent when the path is - // a symlink): safeWriteText commits by renaming onto that referent, and a - // rename across filesystems would fail with EXDEV. - const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // Stage it beside the canonical target: safeWriteText commits by renaming onto + // that referent, and a rename across filesystems would fail with EXDEV. actualTempNewFilePath = path.join( - path.dirname(resolvedTargetPath), + path.dirname(canonicalPath), ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) @@ -114,7 +119,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // creation mode. let stagingMode: number | undefined try { - stagingMode = (fsSync.statSync(resolvedTargetPath).mode & 0o777) | 0o600 + stagingMode = (fsSync.statSync(canonicalPath).mode & 0o777) | 0o600 } catch (statError: unknown) { const statCode = typeof statError === "object" && statError !== null && "code" in statError @@ -136,7 +141,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso tempPath: actualTempNewFilePath, } - await safeWriteText(absoluteFilePath, "", textOptions) + await safeWriteText(canonicalPath, "", textOptions) // If we reach here, the new file is successfully in place. actualTempNewFilePath = null From 5ce461338da756f08be65e383f268a5fdd14362b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 07:58:04 +0800 Subject: [PATCH 15/47] 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 964ec83ffdd27548f628b24901006292b6ba9648 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:48:59 +0000 Subject: [PATCH 16/47] chore: trigger a fresh review pass at this head From ed7201083d003bbaf71d39a65c1b9e4f11fb115f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 15:40:33 +0800 Subject: [PATCH 17/47] 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 | 46 ++++++++++++- src/utils/safeWriteJson.ts | 23 +++++-- 4 files changed, 159 insertions(+), 11 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 601d9c9eb9..f13bd31d32 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -46,6 +46,8 @@ vi.mock("fs", async () => { return { ...actualFs, // Spread actual implementations createWriteStream: vi.fn(actualFs.createWriteStream) as any, // Default to actual, but mockable + // Wrapped so a test can fail the parent-directory fsync that follows the commit rename. + fsyncSync: vi.fn(actualFs.fsyncSync), } }) @@ -106,9 +108,51 @@ describe("safeWriteJson", () => { } } + // Durability of the commit (Persistence Integrity) + test.skipIf(process.platform === "win32")( + "keeps the published target when the commit landed but the directory fsync failed", + async () => { + const target = path.join(tempDir, "durable.json") + // The first fsync is the staged file, the second is the parent directory AFTER the + // commit rename. Failing only the second one is the PublishNotDurableError case: + // the bytes are in place, only their durability is unconfirmed. + let fsyncCalls = 0 + fsSyncActual.fsyncSync.mockImplementation(() => { + fsyncCalls++ + if (fsyncCalls === 2) { + throw Object.assign(new Error("EIO: i/o error"), { code: "EIO" }) + } + }) + + const payload = { committed: "value" } + await expect(safeWriteJson(target, payload)).rejects.toThrow(/could not confirm it is durable/) + expect(fsyncCalls).toBe(2) + + // The staged file was renamed onto the target, so the catch path must not treat that + // path as a leftover temp file: the new content has to survive the rejection. + expect(await readFileContent(target)).toEqual(payload) + }, + ) + // Staging permissions test.skipIf(process.platform === "win32")( - "stages the temp file with the existing target's mode instead of the process default", + "omits an explicit mode for an absent target so the file takes the umask default", + async () => { + const target = path.join(tempDir, "absent-target.json") + await safeWriteJson(target, { fresh: 1 }) + + // Mirroring a mode only makes sense when there IS a target to mirror. Inheriting a + // mode here would either pin 0o600 for a brand new file or, worse, mask the umask. + const streamCall = vi.mocked(fsSyncActual.createWriteStream).mock.calls.at(-1) + expect((streamCall?.[1] as { mode?: number } | undefined)?.mode).toBeUndefined() + + const mode = (await fs.stat(target)).mode & 0o777 + expect(mode).toBe(0o666 & ~process.umask()) + }, + ) + + 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 }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index dd575917d4..e0827cea7f 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,7 +4,12 @@ import * as path from "path" import { JsonStreamStringify } from "json-stream-stringify" -import { resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../services/file-safety/safeWriteText" +import { + resolvePublishTarget, + safeWriteText, + PublishNotDurableError, + type SafeWriteTextOptions, +} from "../services/file-safety/safeWriteText" import { acquireFileLock } from "./fileLock" @@ -148,12 +153,20 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } catch (originalError) { console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) + // PublishNotDurableError is the one failure where the commit rename DID land: + // the staged path was renamed onto the target, so it is no longer a leftover + // temp file and must never be treated as one below. Only the durability of the + // directory entry is unconfirmed; the content is in place. + if (originalError instanceof PublishNotDurableError) { + actualTempNewFilePath = null + } + const newFileToCleanupWithinCatch = actualTempNewFilePath - // A failed safeWriteText leaves the target untouched (the commit rename never - // landed). Clean up the .new file if it still exists (safeWriteText also - // cleans up its tempPath on failure; this is a safety net in case its - // cleanup missed it). + // Any other failure means the commit rename never landed, so the target still + // holds the previous bytes. Clean up the staged file if it still exists + // (safeWriteText also cleans up its tempPath on failure; this is a safety net + // in case its cleanup missed it). if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) From 1a0430c4f91be262ca6362898e9ff3c2e8e92188 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 15:52:04 +0800 Subject: [PATCH 18/47] test(file-safety): wrap the fsync spy so the durability case type-checks compile flagged TS2339: the mocked fs namespace keeps the plain (fd: number) => void type, so mockImplementation is only reachable through vi.mocked(). Local: tsc --noEmit clean (0 errors) and 68 passed / 8 skipped across the two specs. --- src/utils/__tests__/safeWriteJson.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index f13bd31d32..5e125347e1 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -117,7 +117,7 @@ describe("safeWriteJson", () => { // commit rename. Failing only the second one is the PublishNotDurableError case: // the bytes are in place, only their durability is unconfirmed. let fsyncCalls = 0 - fsSyncActual.fsyncSync.mockImplementation(() => { + vi.mocked(fsSyncActual.fsyncSync).mockImplementation(() => { fsyncCalls++ if (fsyncCalls === 2) { throw Object.assign(new Error("EIO: i/o error"), { code: "EIO" }) From 7470a19dc07cd287cfb3ee15ffea48840287e356 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 20:36:59 +0800 Subject: [PATCH 19/47] 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 36f5323f19..ca51e8611e 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" @@ -1157,6 +1158,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 ac721f19a6..5f9654b87f 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -790,6 +790,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({} as any) 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 157946fa029f079cef2fd579f8a2a9b58dc0b93c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 20:47:17 +0800 Subject: [PATCH 20/47] 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 41c1de385699ea9998884e5e26f4ebfed311ec25 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 00:31:26 +0800 Subject: [PATCH 21/47] 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 f65e913aa618c1e52924156210874ac8e84d732d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 00:40:29 +0800 Subject: [PATCH 22/47] 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 5f9654b87f..153b4d3d1c 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -818,6 +818,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({} as any) From 843176e2d3e857b212c2e9767e2a52a926c33657 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 02:14:52 +0800 Subject: [PATCH 23/47] 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 4b2193cd39143cf1d43f38eaaf275c86b77659df Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 03:22:08 +0800 Subject: [PATCH 24/47] 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 4d45c8797488972d24a9963523a4a737f796996f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 04:32:10 +0800 Subject: [PATCH 25/47] 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 aa85a3db65b5ae88b8d977c1a1c001e888f13d2d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 04:41:45 +0800 Subject: [PATCH 26/47] 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 4f0a85c7b8b14763590e3e48cca3d549e31361d5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:32:32 +0800 Subject: [PATCH 27/47] 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 fa740b088900d67869bce8e595ce418742aad6ac Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:36:17 +0800 Subject: [PATCH 28/47] 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 823a0e7788a3a1135e563a9d57db8d08c0086ee3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 05:57:31 +0800 Subject: [PATCH 29/47] 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 153b4d3d1c..a11485ae24 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -834,6 +834,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 568f981c533ffa801cf3f12f6bea697543a06460 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 06:51:46 +0800 Subject: [PATCH 30/47] 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 6df83d845f6355dba5ac193cecbc609e25e5e1d6 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 06:56:18 +0800 Subject: [PATCH 31/47] 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 80de8fb6262f83975333dababddfe56229d35615 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 07:25:17 +0800 Subject: [PATCH 32/47] 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 de893fa03976e4cc795f2beb4aeea47320e85ae1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 08:27:43 +0800 Subject: [PATCH 33/47] 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 110f9445a0fda5df6d6d404c3581a049fe972c03 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:03:35 +0800 Subject: [PATCH 34/47] 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 56dad4da1d3149ff8a4380859b3405029fd6976b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:05:29 +0800 Subject: [PATCH 35/47] 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 4468ee716d13cf98ca36e88447c0f741d258b0fb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 09:41:23 +0800 Subject: [PATCH 36/47] 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 65e6dc9bb40efc00ea05b6665c8bdc40a178877f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 10:50:43 +0800 Subject: [PATCH 37/47] 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 1a2c41bdfc75b73ffa8b4adce169baefa660cab3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 11:23:46 +0800 Subject: [PATCH 38/47] 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 30cefbb0e0444cbf840eccf93279498731ee18a5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sat, 10 Oct 2026 17:59:09 +0800 Subject: [PATCH 39/47] 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/eslint-suppressions.json | 3462 ++++++++--------- 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 +- src/utils/__tests__/safeWriteJson.test.ts | 59 +- src/utils/safeWriteJson.ts | 15 +- 8 files changed, 2099 insertions(+), 1997 deletions(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0b2e865e70..ad2a2cddb9 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1,1732 +1,1732 @@ { - "__mocks__/fs/promises.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/ClineProvider.delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "__tests__/ClineProvider.history-resume-delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 70 - } - }, - "__tests__/abandonSubtask.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "__tests__/api-subtask.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/delegation-concurrent.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "__tests__/delegation-events.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "__tests__/migrateSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "__tests__/nested-delegation-resume.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "__tests__/new-task-delegation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "activate/CodeActionProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "activate/__tests__/CodeActionProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "activate/__tests__/handleUri.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "activate/__tests__/registerCommands.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "activate/registerCodeActions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "activate/registerCommands.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "activate/registerTerminalActions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/anthropic-vertex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 31 - } - }, - "api/providers/__tests__/anthropic.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/base-openai-compatible-provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/base-provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/__tests__/bedrock-custom-arn.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "api/providers/__tests__/bedrock-error-handling.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/providers/__tests__/bedrock-inference-profiles.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "api/providers/__tests__/bedrock-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "api/providers/__tests__/bedrock-reasoning.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/bedrock.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 37 - } - }, - "api/providers/__tests__/deepseek.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "api/providers/__tests__/gemini-handler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "api/providers/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 33 - } - }, - "api/providers/__tests__/kenari.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/kimi-code.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/lite-llm.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 36 - } - }, - "api/providers/__tests__/lm-studio-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/__tests__/mimo.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "api/providers/__tests__/minimax.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/moonshot.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "api/providers/__tests__/native-ollama.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "api/providers/__tests__/openai-codex-native-tool-calls.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "api/providers/__tests__/openai-codex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "api/providers/__tests__/openai-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "api/providers/__tests__/openai-native-usage.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "api/providers/__tests__/openai-native.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 74 - } - }, - "api/providers/__tests__/openai-timeout.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/openai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/__tests__/opencode-go.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "api/providers/__tests__/poe.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/__tests__/qwen-code-native-tools.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/sambanova.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/unbound.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/__tests__/vertex.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/__tests__/zai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/anthropic-vertex.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/anthropic.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/base-openai-compatible-provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/providers/base-provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/providers/bedrock.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "api/providers/deepseek.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/fetchers/__tests__/kenari.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/kimi-code.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/lmstudio.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/fetchers/__tests__/moonshot.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/ollama.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "api/providers/fetchers/__tests__/opencode-go.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/providers/fetchers/litellm.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/gemini.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/lite-llm.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/providers/lm-studio.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/mimo.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/providers/moonshot.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/native-ollama.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/openai-codex.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 31 - } - }, - "api/providers/openai-native.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 28 - } - }, - "api/providers/openai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/openrouter.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/providers/poe.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/qwen-code.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/providers/requesty.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/unbound.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/providers/utils/__tests__/error-handler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "api/providers/utils/__tests__/image-generation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "api/providers/utils/__tests__/timeout-config.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/providers/utils/error-handler.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/providers/xai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "api/transform/__tests__/ai-sdk.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/anthropic-filter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/transform/__tests__/bedrock-converse-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/gemini-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/mistral-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/__tests__/model-params.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/__tests__/openai-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 49 - } - }, - "api/transform/__tests__/r1-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/__tests__/reasoning.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/__tests__/responses-api-input.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/__tests__/responses-api-stream.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/__tests__/zai-format.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "api/transform/ai-sdk.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/bedrock-converse-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "api/transform/cache-strategy/__tests__/cache-strategy.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "api/transform/caching/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/gemini-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "api/transform/openai-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "api/transform/r1-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "api/transform/responses-api-input.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "api/transform/responses-api-stream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "api/transform/zai-format.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/assistant-message/NativeToolCallParser.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/assistant-message/presentAssistantMessage.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/checkpoints/__tests__/checkpoint.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/checkpoints/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/condense/__tests__/condense.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/condense/__tests__/foldedFileContext.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "core/condense/__tests__/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 24 - } - }, - "core/config/ContextProxy.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/config/CustomModesManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/ProviderSettingsManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/config/__tests__/ContextProxy.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/config/__tests__/CustomModesManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/config/__tests__/ProviderSettingsManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/context-management/__tests__/context-management.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/context-tracking/__tests__/FileContextTracker.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/context/context-management/__tests__/context-error-handling.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/context/context-management/context-error-handling.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/diff/stats.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/environment/__tests__/getEnvironmentDetails.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/ignore/__tests__/RooIgnoreController.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/mentions/__tests__/processUserContentMentions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/mentions/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/mentions/processUserContentMentions.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/message-manager/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "core/message-manager/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/prompts/__tests__/add-custom-instructions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/__tests__/get-prompt-component.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/__tests__/responses-rooignore.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "core/prompts/__tests__/system-prompt.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/prompts/sections/__tests__/custom-instructions-global.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "core/prompts/sections/__tests__/custom-instructions.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 55 - } - }, - "core/prompts/sections/__tests__/system-info.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "core/prompts/tools/filter-tools-for-mode.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/prompts/tools/native-tools/__tests__/read_file.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/task-persistence/__tests__/importRooTaskHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/task-persistence/__tests__/taskMessages.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/task-persistence/apiMessages.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/task/Task.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "core/task/__tests__/Task.dispose.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/Task.persistence.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/task/__tests__/Task.sticky-profile-race.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/Task.throttle.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 24 - } - }, - "core/task/__tests__/apiConversationHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, - "core/task/__tests__/ask-clear-approval-buttons.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 19 - } - }, - "core/task/__tests__/ask-queued-message-drain.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, - "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 14 - } - }, - "core/task/__tests__/grace-retry-errors.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/task/__tests__/grounding-sources.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/task/__tests__/native-tools-filtering.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/__tests__/new-task-isolation.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/task/__tests__/reasoning-preservation.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "core/task/__tests__/task-tool-history.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/task/apiConversationHistory.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/tools/BaseTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/CodebaseSearchTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/GenerateImageTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/NewTaskTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/ReadFileTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/tools/ToolRepetitionDetector.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/UpdateTodoListTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/UseMcpToolTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/ReadCommandOutputTool.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "core/tools/__tests__/attemptCompletionTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 11 - } - }, - "core/tools/__tests__/editFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/tools/__tests__/editTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/__tests__/executeCommand.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/tools/__tests__/executeCommandTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/tools/__tests__/generateImageTool.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "core/tools/__tests__/listFilesTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "core/tools/__tests__/mcpServerRestriction.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "core/tools/__tests__/newTaskTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 26 - } - }, - "core/tools/__tests__/readFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 96 - } - }, - "core/tools/__tests__/runSlashCommandTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/searchReplaceTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/skillTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/tools/__tests__/updateTodoListTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/useMcpToolTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 21 - } - }, - "core/tools/__tests__/validateToolUse.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/tools/__tests__/writeToFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/tools/helpers/toolResultFormatting.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/tools/validateToolUse.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/webview/ClineProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 34 - } - }, - "core/webview/__tests__/ClineProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 196 - } - }, - "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 27 - } - }, - "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "core/webview/__tests__/ClineProvider.taskHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "core/webview/__tests__/checkpointRestoreHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/diagnosticsHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "core/webview/__tests__/messageEnhancer.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "core/webview/__tests__/skillsMessageHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/telemetrySettingsTracking.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/webviewMessageHandler.delete.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "core/webview/__tests__/webviewMessageHandler.edit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 13 - } - }, - "core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 56 - } - }, - "core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "core/webview/__tests__/webviewMessageHandler.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "core/webview/messageEnhancer.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "core/webview/webviewMessageHandler.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "extension/__tests__/api-delete-queued-message.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "extension/__tests__/api-send-message.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "extension/api.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "i18n/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "i18n/setup.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/editor/DiffViewProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/editor/__tests__/DiffViewProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 310 - } - }, - "integrations/editor/__tests__/EditorUtils.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "integrations/kimi-code/__tests__/oauth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/misc/__tests__/export-markdown.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/misc/__tests__/extract-text.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "integrations/misc/__tests__/line-counter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/misc/__tests__/open-file.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 11 - } - }, - "integrations/misc/__tests__/performance/processCarriageReturns.benchmark.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "integrations/terminal/__tests__/OutputInterceptor.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "integrations/terminal/__tests__/TerminalProcess.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "integrations/terminal/__tests__/TerminalProcess.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/TerminalProcessInterpretExitCode.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "integrations/terminal/__tests__/TerminalProfile.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "integrations/terminal/__tests__/TerminalRegistry.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "integrations/terminal/__tests__/setupTerminalTests.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/bashStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/cmdStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/terminal/__tests__/streamUtils/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "integrations/terminal/__tests__/streamUtils/pwshStream.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "integrations/theme/getTheme.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "services/__tests__/zoo-code-auth.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/checkpoints/__tests__/ShadowCheckpointService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/__tests__/config-manager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/__tests__/manager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 84 - } - }, - "services/code-index/__tests__/orchestrator.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, - "services/code-index/__tests__/service-factory.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 43 - } - }, - "services/code-index/embedders/__tests__/bedrock.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "services/code-index/embedders/__tests__/gemini.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/mistral.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/ollama.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 15 - } - }, - "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 28 - } - }, - "services/code-index/embedders/__tests__/openai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "services/code-index/embedders/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/bedrock.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/embedders/ollama.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/embedders/openai-compatible.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/openai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/code-index/embedders/openrouter.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/interfaces/vector-store.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/orchestrator.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/processors/__tests__/file-watcher.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "services/code-index/processors/__tests__/parser.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "services/code-index/processors/__tests__/scanner.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 26 - } - }, - "services/code-index/processors/file-watcher.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/processors/scanner.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/semble/__tests__/provider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, - "services/code-index/semble/__tests__/semble-cli.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/semble/__tests__/semble-downloader.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 61 - } - }, - "services/code-index/semble/provider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/semble/semble-cli.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/code-index/shared/__tests__/validation-helpers.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/code-index/shared/validation-helpers.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 87 - } - }, - "services/code-index/vector-store/qdrant-client.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/glob/__tests__/gitignore-integration.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/glob/__tests__/gitignore-test.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/glob/__tests__/list-files-limit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "services/glob/__tests__/list-files.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 22 - } - }, - "services/marketplace/MarketplaceManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/marketplace/SimpleInstaller.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "services/marketplace/__tests__/MarketplaceManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/marketplace/__tests__/SimpleInstaller.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 17 - } - }, - "services/marketplace/__tests__/marketplace-setting-check.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/mcp/McpHub.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "services/mcp/McpOAuthClientProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/mcp/McpServerManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/mcp/__tests__/McpHub.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 150 - } - }, - "services/mcp/__tests__/McpOAuthClientProvider.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "services/mcp/__tests__/SecretStorageService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/mcp/utils/__tests__/callbackServer.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/mcp/utils/__tests__/oauth.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, - "services/mcp/utils/callbackServer.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/mcp/utils/oauth.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/mdm/__tests__/MdmService.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "services/ripgrep/__tests__/diagnostic.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "services/roo-config/__tests__/index.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 20 - } - }, - "services/roo-config/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/rules/__tests__/rules.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/search/__tests__/file-search.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "services/skills/__tests__/SkillsManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/tree-sitter/__tests__/helpers.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "services/tree-sitter/__tests__/markdownParser.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/__tests__/api.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, - "shared/__tests__/embeddingModels.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/__tests__/modes-empty-prompt-component.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/api.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/checkExistApiConfig.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/cost.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/parse-command.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/support-prompt.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "shared/tools.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/utils/__tests__/requesty.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/__tests__/autoImportSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 16 - } - }, - "utils/__tests__/enhance-prompt.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "utils/__tests__/git.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 95 - } - }, - "utils/__tests__/json-schema.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "utils/__tests__/migrateSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "utils/__tests__/outputChannelLogger.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "utils/__tests__/safeWriteJson.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 26 - } - }, - "utils/__tests__/shell.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 35 - } - }, - "utils/__tests__/storage.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 25 - } - }, - "utils/__tests__/tiktoken.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/config.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/export.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "utils/safeWriteJson.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "utils/tts.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "vitest.setup.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - } -} \ No newline at end of file + "__mocks__/fs/promises.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/ClineProvider.delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "__tests__/ClineProvider.history-resume-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 70 + } + }, + "__tests__/abandonSubtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "__tests__/api-subtask.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/delegation-concurrent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/delegation-events.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "__tests__/migrateSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "__tests__/nested-delegation-resume.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "__tests__/new-task-delegation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "activate/CodeActionProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/__tests__/CodeActionProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "activate/__tests__/handleUri.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "activate/__tests__/registerCommands.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCodeActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "activate/registerCommands.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "activate/registerTerminalActions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/anthropic-vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/__tests__/anthropic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/base-openai-compatible-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/base-provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/__tests__/bedrock-custom-arn.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/bedrock-error-handling.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/__tests__/bedrock-inference-profiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "api/providers/__tests__/bedrock-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "api/providers/__tests__/bedrock-reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 37 + } + }, + "api/providers/__tests__/deepseek.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "api/providers/__tests__/gemini-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "api/providers/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 33 + } + }, + "api/providers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/lite-llm.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 36 + } + }, + "api/providers/__tests__/lm-studio-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/mimo.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "api/providers/__tests__/minimax.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "api/providers/__tests__/native-ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "api/providers/__tests__/openai-codex-native-tool-calls.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "api/providers/__tests__/openai-codex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "api/providers/__tests__/openai-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "api/providers/__tests__/openai-native-usage.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "api/providers/__tests__/openai-native.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 74 + } + }, + "api/providers/__tests__/openai-timeout.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/__tests__/poe.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/__tests__/qwen-code-native-tools.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/sambanova.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/unbound.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/__tests__/vertex.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/__tests__/zai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/anthropic-vertex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/anthropic.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/base-openai-compatible-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/base-provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/providers/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "api/providers/deepseek.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/kenari.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/kimi-code.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/lmstudio.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/fetchers/__tests__/modelEndpointCache.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/moonshot.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/ollama.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "api/providers/fetchers/__tests__/opencode-go.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/fetchers/__tests__/zoo-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/fetchers/litellm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/gemini.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/lite-llm.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/providers/lm-studio.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/mimo.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/providers/moonshot.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/native-ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openai-codex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, + "api/providers/openai-native.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 28 + } + }, + "api/providers/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/providers/poe.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/qwen-code.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/providers/requesty.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/unbound.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/providers/utils/__tests__/error-handler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "api/providers/utils/__tests__/image-generation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/providers/utils/__tests__/timeout-config.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/providers/utils/error-handler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/providers/xai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "api/transform/__tests__/ai-sdk.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/anthropic-filter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/__tests__/bedrock-converse-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/gemini-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/mistral-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/model-params.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/openai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 49 + } + }, + "api/transform/__tests__/r1-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/__tests__/reasoning.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/__tests__/responses-api-input.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/__tests__/responses-api-stream.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/__tests__/zai-format.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "api/transform/ai-sdk.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/bedrock-converse-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "api/transform/cache-strategy/__tests__/cache-strategy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "api/transform/caching/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/gemini-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "api/transform/openai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "api/transform/r1-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "api/transform/responses-api-input.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "api/transform/responses-api-stream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "api/transform/zai-format.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/assistant-message/NativeToolCallParser.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/assistant-message/presentAssistantMessage.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/checkpoints/__tests__/checkpoint.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/checkpoints/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/condense/__tests__/condense.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/condense/__tests__/foldedFileContext.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/condense/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/config/ContextProxy.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/CustomModesManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/ProviderSettingsManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/config/__tests__/ContextProxy.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/config/__tests__/CustomModesManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/config/__tests__/ProviderSettingsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/context-management/__tests__/context-management.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context-tracking/__tests__/FileContextTracker.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/context/context-management/__tests__/context-error-handling.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/context/context-management/context-error-handling.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/diff/stats.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/environment/__tests__/getEnvironmentDetails.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/ignore/__tests__/RooIgnoreController.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/__tests__/processUserContentMentions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/mentions/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/mentions/processUserContentMentions.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/message-manager/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/message-manager/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/prompts/__tests__/add-custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/get-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/__tests__/responses-rooignore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/prompts/__tests__/system-prompt.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/prompts/sections/__tests__/custom-instructions-global.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "core/prompts/sections/__tests__/custom-instructions.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 55 + } + }, + "core/prompts/sections/__tests__/system-info.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/prompts/tools/filter-tools-for-mode.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/prompts/tools/native-tools/__tests__/read_file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/TaskHistoryStore.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/task-persistence/__tests__/importRooTaskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task-persistence/__tests__/taskMessages.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task-persistence/apiMessages.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/task/Task.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "core/task/__tests__/Task.dispose.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.persistence.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/task/__tests__/Task.sticky-profile-race.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/Task.throttle.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 24 + } + }, + "core/task/__tests__/apiConversationHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "core/task/__tests__/ask-clear-approval-buttons.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 19 + } + }, + "core/task/__tests__/ask-queued-message-drain.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 18 + } + }, + "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 14 + } + }, + "core/task/__tests__/grace-retry-errors.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/grounding-sources.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/task/__tests__/native-tools-filtering.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/__tests__/new-task-isolation.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/task/__tests__/reasoning-preservation.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/task/__tests__/task-tool-history.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/task/apiConversationHistory.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/BaseTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/CodebaseSearchTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/GenerateImageTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/NewTaskTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/ReadFileTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/tools/ToolRepetitionDetector.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UpdateTodoListTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/UseMcpToolTool.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/ReadCommandOutputTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "core/tools/__tests__/attemptCompletionTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "core/tools/__tests__/editFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/__tests__/editTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/__tests__/executeCommand.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/tools/__tests__/executeCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/tools/__tests__/generateImageTool.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/tools/__tests__/listFilesTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "core/tools/__tests__/mcpServerRestriction.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "core/tools/__tests__/newTaskTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "core/tools/__tests__/readFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 96 + } + }, + "core/tools/__tests__/runSlashCommandTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/searchReplaceTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/skillTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/tools/__tests__/updateTodoListTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/useMcpToolTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 21 + } + }, + "core/tools/__tests__/validateToolUse.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/tools/__tests__/writeToFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/tools/helpers/toolResultFormatting.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/tools/validateToolUse.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "core/webview/ClineProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 34 + } + }, + "core/webview/__tests__/ClineProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 196 + } + }, + "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 27 + } + }, + "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "core/webview/__tests__/ClineProvider.taskHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "core/webview/__tests__/checkpointRestoreHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/diagnosticsHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "core/webview/__tests__/messageEnhancer.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "core/webview/__tests__/skillsMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/telemetrySettingsTracking.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.delete.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "core/webview/__tests__/webviewMessageHandler.edit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.importRooHistory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 13 + } + }, + "core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 56 + } + }, + "core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "core/webview/__tests__/webviewMessageHandler.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "core/webview/messageEnhancer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "core/webview/webviewMessageHandler.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "extension/__tests__/api-delete-queued-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "extension/__tests__/api-send-message.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "extension/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "i18n/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "i18n/setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/DiffViewProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/editor/__tests__/DiffViewProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 310 + } + }, + "integrations/editor/__tests__/EditorUtils.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/kimi-code/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/export-markdown.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/misc/__tests__/extract-text.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "integrations/misc/__tests__/line-counter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/misc/__tests__/open-file.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 11 + } + }, + "integrations/misc/__tests__/performance/processCarriageReturns.benchmark.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "integrations/terminal/__tests__/OutputInterceptor.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "integrations/terminal/__tests__/TerminalProcess.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "integrations/terminal/__tests__/TerminalProcess.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/TerminalProcessInterpretExitCode.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "integrations/terminal/__tests__/TerminalProfile.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "integrations/terminal/__tests__/TerminalRegistry.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "integrations/terminal/__tests__/setupTerminalTests.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/bashStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/cmdStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/terminal/__tests__/streamUtils/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "integrations/terminal/__tests__/streamUtils/pwshStream.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "integrations/theme/getTheme.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/__tests__/zoo-code-auth.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/checkpoints/__tests__/ShadowCheckpointService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/config-manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/__tests__/manager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 84 + } + }, + "services/code-index/__tests__/orchestrator.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 23 + } + }, + "services/code-index/__tests__/service-factory.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 43 + } + }, + "services/code-index/embedders/__tests__/bedrock.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/code-index/embedders/__tests__/gemini.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/mistral.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/ollama.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 15 + } + }, + "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 28 + } + }, + "services/code-index/embedders/__tests__/openai.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/code-index/embedders/__tests__/openrouter.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/bedrock.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/embedders/ollama.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/embedders/openai-compatible.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openai.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/code-index/embedders/openrouter.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/interfaces/vector-store.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/orchestrator.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/__tests__/file-watcher.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "services/code-index/processors/__tests__/parser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/code-index/processors/__tests__/scanner.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "services/code-index/processors/file-watcher.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/processors/scanner.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/__tests__/provider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 12 + } + }, + "services/code-index/semble/__tests__/semble-cli.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/semble/__tests__/semble-downloader.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 61 + } + }, + "services/code-index/semble/provider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/code-index/semble/semble-cli.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/code-index/shared/__tests__/validation-helpers.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/code-index/shared/validation-helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 87 + } + }, + "services/code-index/vector-store/qdrant-client.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/glob/__tests__/gitignore-integration.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/glob/__tests__/gitignore-test.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/glob/__tests__/list-files-limit.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "services/glob/__tests__/list-files.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 22 + } + }, + "services/marketplace/MarketplaceManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/marketplace/SimpleInstaller.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 7 + } + }, + "services/marketplace/__tests__/MarketplaceManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/marketplace/__tests__/SimpleInstaller.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 17 + } + }, + "services/marketplace/__tests__/marketplace-setting-check.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/McpHub.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 10 + } + }, + "services/mcp/McpOAuthClientProvider.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mcp/McpServerManager.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/__tests__/McpHub.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 150 + } + }, + "services/mcp/__tests__/McpOAuthClientProvider.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/mcp/__tests__/SecretStorageService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/mcp/utils/__tests__/callbackServer.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/mcp/utils/__tests__/oauth.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 9 + } + }, + "services/mcp/utils/callbackServer.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/mcp/utils/oauth.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/mdm/__tests__/MdmService.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "services/ripgrep/__tests__/diagnostic.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "services/roo-config/__tests__/index.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 20 + } + }, + "services/roo-config/index.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/rules/__tests__/rules.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/search/__tests__/file-search.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "services/skills/__tests__/SkillsManager.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "services/tree-sitter/__tests__/helpers.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "services/tree-sitter/__tests__/markdownParser.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/__tests__/api.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 8 + } + }, + "shared/__tests__/embeddingModels.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/__tests__/modes-empty-prompt-component.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/api.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "shared/checkExistApiConfig.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/cost.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/parse-command.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/support-prompt.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 5 + } + }, + "shared/tools.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "shared/utils/__tests__/requesty.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/__tests__/autoImportSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 16 + } + }, + "utils/__tests__/enhance-prompt.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 6 + } + }, + "utils/__tests__/git.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 95 + } + }, + "utils/__tests__/json-schema.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "utils/__tests__/migrateSettings.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 4 + } + }, + "utils/__tests__/outputChannelLogger.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "utils/__tests__/safeWriteJson.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 26 + } + }, + "utils/__tests__/shell.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 35 + } + }, + "utils/__tests__/storage.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 25 + } + }, + "utils/__tests__/tiktoken.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/config.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/export.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 2 + } + }, + "utils/safeWriteJson.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "utils/tts.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, + "vitest.setup.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + } +} diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index ca51e8611e..b43ce12cf0 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1165,7 +1165,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 a11485ae24..399b0a3ca5 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -797,9 +797,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() @@ -834,10 +834,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) } } diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 5e125347e1..92ebb28e7e 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -110,8 +110,8 @@ describe("safeWriteJson", () => { // Durability of the commit (Persistence Integrity) test.skipIf(process.platform === "win32")( - "keeps the published target when the commit landed but the directory fsync failed", - async () => { + "keeps the published target when the commit landed but the directory fsync failed", + async () => { const target = path.join(tempDir, "durable.json") // The first fsync is the staged file, the second is the parent directory AFTER the // commit rename. Failing only the second one is the PublishNotDurableError case: @@ -131,13 +131,13 @@ describe("safeWriteJson", () => { // The staged file was renamed onto the target, so the catch path must not treat that // path as a leftover temp file: the new content has to survive the rejection. expect(await readFileContent(target)).toEqual(payload) - }, + }, ) // Staging permissions test.skipIf(process.platform === "win32")( - "omits an explicit mode for an absent target so the file takes the umask default", - async () => { + "omits an explicit mode for an absent target so the file takes the umask default", + async () => { const target = path.join(tempDir, "absent-target.json") await safeWriteJson(target, { fresh: 1 }) @@ -148,11 +148,11 @@ describe("safeWriteJson", () => { const mode = (await fs.stat(target)).mode & 0o777 expect(mode).toBe(0o666 & ~process.umask()) - }, + }, ) test.skipIf(process.platform === "win32")( - "stages the temp file with the existing target's mode instead of the process default", + "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 }) @@ -216,32 +216,29 @@ describe("safeWriteJson", () => { }, ) - 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 })) + 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" }) - }) + 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() - } - }, - ) + 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", diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index e0827cea7f..921c6a1b9d 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -3,7 +3,6 @@ import * as fsSync from "fs" import * as path from "path" import { JsonStreamStringify } from "json-stream-stringify" - import { resolvePublishTarget, safeWriteText, @@ -11,7 +10,6 @@ import { type SafeWriteTextOptions, } from "../services/file-safety/safeWriteText" - import { acquireFileLock } from "./fileLock" /** @@ -75,7 +73,6 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } - // Acquire the lock before any file operations. `acquireFileLock` owns the // shared advisory lock protocol, so callers that lock the same path with // it (for example task-history deletion) serialize with this write. @@ -196,16 +193,14 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso * @param prettyPrint Whether to format the JSON with indentation. * @returns Promise */ -async function _streamDataToFile( - targetPath: string, - data: any, - prettyPrint = false, - mode?: number, -): Promise { +async function _streamDataToFile(targetPath: string, data: any, prettyPrint = false, mode?: number): Promise { // Stream data to avoid high memory usage for large JSON objects. // mode is explicit because createWriteStream defaults to 0o666 (& ~umask): the // staged file is readable by others until the commit renames it onto the target. - const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8", ...(mode !== undefined ? { mode } : {}) }) + const fileWriteStream = fsSync.createWriteStream(targetPath, { + encoding: "utf8", + ...(mode !== undefined ? { mode } : {}), + }) // JsonStreamStringify traverses the object and streams tokens directly // The 'spaces' parameter adds indentation during streaming, not via a separate pass From 64923c631371903288178f01b9d7862c174b39ab 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 40/47] 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 73a842aabeb11c81b98df89541957f0014327a72 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 41/47] 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 1476e58594151f36c88e3020339b5175e3920814 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 42/47] 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 399b0a3ca5..81161ed04e 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -812,9 +812,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 e25d64258744e471bb88304cf444af7e8260c92e 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 43/47] 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 796e4120b0b2ac1bdb24b1ff82b39a5b98c06d3d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 09:22:07 +0800 Subject: [PATCH 44/47] 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 ++++++++++++++----- src/utils/__tests__/safeWriteJson.test.ts | 34 ++++++++++ src/utils/safeWriteJson.ts | 14 +++-- 5 files changed, 164 insertions(+), 40 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 81161ed04e..756511f3af 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -21,10 +21,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", () => ({ @@ -850,6 +856,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({} as any) 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 + } } diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 92ebb28e7e..a1eb353945 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -5,6 +5,14 @@ import * as os from "os" import { safeWriteJson } from "../safeWriteJson" +// Pass-through spy over the real publish primitive: every test keeps the real +// behaviour, and one test can hand safeWriteJson the post-commit DaclRestoreError +// without a real Windows DACL failure. +vi.mock("../../services/file-safety/safeWriteText", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, safeWriteText: vi.fn(actual.safeWriteText) } +}) + // Capture actual implementations before the vi.mock factory runs, // so they are never wrapped by vi.fn() — avoids infinite recursion when // test mockImplementation callbacks delegate to the real implementation. @@ -134,6 +142,32 @@ describe("safeWriteJson", () => { }, ) + // Security Boundaries: a landed commit whose DACL was lost + test("does not treat the staged path as a leftover when the commit landed but the DACL restore failed", async () => { + const { safeWriteText, DaclRestoreError } = await import("../../services/file-safety/safeWriteText") + const target = path.join(tempDir, "restore-fail.json") + + // The commit rename landed; only putting the saved DACL back failed. The consumer + // must classify this as a landed commit - not a failed publish whose staged file + // is a leftover to remove - and it must still rethrow, so no caller observes + // success for a publish whose DACL was not restored. + vi.mocked(safeWriteText).mockImplementationOnce(async () => { + throw new DaclRestoreError(target, null) + }) + + await expect(safeWriteJson(target, { committed: "value" })).rejects.toBeInstanceOf(DaclRestoreError) + + // The staged path was consumed by the commit rename, so the catch path must not + // unlink it as a leftover temp file. + const streamCall = vi + .mocked(fsSyncActual.createWriteStream) + .mock.calls.find((call) => String(call[0]).includes(".new_")) + expect(streamCall).toBeDefined() + const stagedPath = String(streamCall![0]) + const stagedUnlinks = vi.mocked(fs.unlink).mock.calls.filter((call) => String(call[0]) === stagedPath) + expect(stagedUnlinks).toHaveLength(0) + }) + // Staging permissions test.skipIf(process.platform === "win32")( "omits an explicit mode for an absent target so the file takes the umask default", diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 921c6a1b9d..2c978824ba 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -6,6 +6,7 @@ import { JsonStreamStringify } from "json-stream-stringify" import { resolvePublishTarget, safeWriteText, + DaclRestoreError, PublishNotDurableError, type SafeWriteTextOptions, } from "../services/file-safety/safeWriteText" @@ -150,11 +151,14 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } catch (originalError) { console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) - // PublishNotDurableError is the one failure where the commit rename DID land: - // the staged path was renamed onto the target, so it is no longer a leftover - // temp file and must never be treated as one below. Only the durability of the - // directory entry is unconfirmed; the content is in place. - if (originalError instanceof PublishNotDurableError) { + // PublishNotDurableError and DaclRestoreError are the failures where the commit + // rename DID land: the staged path was renamed onto the target, so it is no + // longer a leftover temp file and must never be treated as one below. For + // PublishNotDurableError only the durability of the directory entry is + // unconfirmed; for DaclRestoreError the content is committed and durable but the + // saved access rights could not be put back. Both rethrow, so neither can be + // observed as a successful save. + if (originalError instanceof PublishNotDurableError || originalError instanceof DaclRestoreError) { actualTempNewFilePath = null } From d10554da94cab9f07d1651addf4d53285e50bdec Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 16:10:43 +0800 Subject: [PATCH 45/47] 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. --- .../__tests__/safeWriteText.spec.ts | 11 +++++++++-- src/utils/__tests__/safeWriteJson.test.ts | 18 +++++++++++++++--- 2 files changed, 24 insertions(+), 5 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) }) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index a1eb353945..1b024205d6 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -151,19 +151,31 @@ describe("safeWriteJson", () => { // must classify this as a landed commit - not a failed publish whose staged file // is a leftover to remove - and it must still rethrow, so no caller observes // success for a publish whose DACL was not restored. - vi.mocked(safeWriteText).mockImplementationOnce(async () => { + // The mock has to reproduce the state a real DaclRestoreError arrives with: the + // commit rename already landed, and only the DACL restore afterwards failed. Throwing + // without the rename would let the no-unlink assertion pass on a write that never + // committed, which is a different situation with a different correct answer. + vi.mocked(safeWriteText).mockImplementationOnce(async (_target, _content, textOptions) => { + // fs.rename is a pass-through spy over the real rename, so this is the real commit. + await fs.rename(String(textOptions?.tempPath), target) throw new DaclRestoreError(target, null) }) await expect(safeWriteJson(target, { committed: "value" })).rejects.toBeInstanceOf(DaclRestoreError) - // The staged path was consumed by the commit rename, so the catch path must not - // unlink it as a leftover temp file. const streamCall = vi .mocked(fsSyncActual.createWriteStream) .mock.calls.find((call) => String(call[0]).includes(".new_")) expect(streamCall).toBeDefined() const stagedPath = String(streamCall![0]) + + // The commit really landed: the new bytes are at the target and the staged name is + // gone, so this is the post-commit state the catch path has to classify. + expect(await readFileContent(target)).toEqual({ committed: "value" }) + expect(await fileExists(stagedPath)).toBe(false) + + // The staged path was consumed by the commit rename, so the catch path must not + // unlink it as a leftover temp file. const stagedUnlinks = vi.mocked(fs.unlink).mock.calls.filter((call) => String(call[0]) === stagedPath) expect(stagedUnlinks).toHaveLength(0) }) From b98c07bb31788c2d92bb068acfd02b1548e97134 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 16:18:49 +0800 Subject: [PATCH 46/47] 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 8c78c202fb04f8066ca169f7ea3f4d386f806692 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Sun, 11 Oct 2026 21:08:43 +0800 Subject: [PATCH 47/47] 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)