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..d0730d1254
--- /dev/null
+++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts
@@ -0,0 +1,58 @@
+import * as fs from "fs/promises"
+import * as os from "os"
+import * as path from "path"
+
+import { safeWriteText } from "../safeWriteText"
+
+// No fs mocks in this file: the point is to assert what a real filesystem ends up
+// holding after a publish attempt, which the mocked spec cannot show. The failure is
+// provoked with real filesystem semantics rather than with a stubbed call.
+describe("safeWriteText against a real filesystem", () => {
+ let dir: string
+
+ beforeEach(async () => {
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-text-int-"))
+ })
+
+ afterEach(async () => {
+ await fs.rm(dir, { recursive: true, force: true })
+ })
+
+ // The commit-rename failure mode is covered deterministically in safeWriteText.spec.ts
+ // ('a failed commit does not move the target, so nothing has to be rolled back'): on a real
+ // filesystem there is no portable way to make only the rename fail - the ESM fs namespace
+ // cannot be spied, and read-only-parent / sticky-bit / cross-device setups are not portable.
+ it("publishes the new bytes and leaves no staging or backup residue", async () => {
+ const targetPath = path.join(dir, "target.txt")
+ await fs.writeFile(targetPath, "old bytes")
+
+ // No platform override: the real platform's own durability and ACL steps run, and this
+ // case is the successful restore. A failed restore now throws DaclRestoreError after the
+ // commit rename has already happened (the focused unit test "win32 DACL: a failed restore
+ // is an error and the dump is still unlinked" covers that path), so on a machine where
+ // icacls cannot put a saved ACL back into a throwaway temp directory this publish
+ // reports that error instead of resolving, and the residue assertions below are reached
+ // only where the restore worked.
+ await safeWriteText(targetPath, "new bytes", { backup: true })
+
+ expect(await fs.readFile(targetPath, "utf8")).toBe("new bytes")
+ expect(await fs.readdir(dir)).toEqual(["target.txt"])
+ })
+
+ it("leaves the target bytes untouched when the backup copy cannot be made", async () => {
+ // A regular file cannot be renamed over a directory, so the step-3 backup copy fails
+ // on a real filesystem with no mocking. Note what this case does NOT cover: the commit
+ // rename is never reached, because the backup failure aborts the write first.
+ const targetPath = path.join(dir, "target-dir")
+ await fs.mkdir(targetPath)
+ const inside = path.join(targetPath, "payload.txt")
+ await fs.writeFile(inside, "original bytes")
+
+ await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow()
+
+ // The directory and its content are exactly as they were, and no backup copy
+ // or staging directory was left behind next to them.
+ expect(await fs.readFile(inside, "utf8")).toBe("original bytes")
+ expect(await fs.readdir(dir)).toEqual(["target-dir"])
+ })
+})
diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts
new file mode 100644
index 0000000000..37f1a5234c
--- /dev/null
+++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts
@@ -0,0 +1,1813 @@
+import * as fs from "fs/promises"
+import * as fsSync from "fs"
+import type { BigIntStats } from "fs"
+import { execFile } from "child_process"
+import type { ChildProcess } from "child_process"
+import * as path from "path"
+
+import {
+ DaclInspectionError,
+ DaclRestoreError,
+ OrphanedBackupError,
+ PostCommitDurabilityError,
+ resolveLockKey,
+ safeWriteText,
+ StagingPathError,
+ type PublishResidue,
+ type SafeWriteTextOptions,
+} from "../safeWriteText"
+
+// Full mock for fs/promises — all methods are vi.fn() stubs
+vi.mock("fs/promises", () => ({
+ copyFile: vi.fn(),
+ chmod: vi.fn(),
+ mkdir: vi.fn(),
+ access: vi.fn(),
+ rename: vi.fn(),
+ unlink: vi.fn(),
+ rmdir: vi.fn(),
+ realpath: vi.fn(),
+ lstat: vi.fn(),
+ readlink: vi.fn(),
+}))
+
+// Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare
+// class stub so tests can build minimal Stats stand-ins via its prototype.
+vi.mock("fs", () => ({
+ openSync: vi.fn(),
+ writeSync: vi.fn(),
+ closeSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ fsyncSync: vi.fn(),
+ chmodSync: vi.fn(),
+ fchmodSync: vi.fn(),
+ statSync: vi.fn(),
+ Stats: class Stats {},
+}))
+
+// Mock child_process.execFile (callback-based — must invoke callback to resolve)
+vi.mock("child_process", () => ({
+ execFile: vi.fn((cmd, args, opts, cb) => {
+ if (typeof cb === "function") cb(null)
+ }),
+}))
+
+// Minimal stand-in for the ChildProcess that callback-form execFile returns.
+const fakeChild = { kill: () => true } as unknown as ChildProcess
+
+// Helper that mirrors safeWriteText's path resolution exactly
+function _resolvedTarget(filePath: string): string {
+ return path.resolve(filePath)
+}
+function _dirPath(filePath: string): string {
+ return path.dirname(_resolvedTarget(filePath))
+}
+// Minimal Stats stand-in: the SUT only reads `.mode` from it.
+// Async lstat stand-in: the SUT only asks whether the path is a link or a file.
+// Built on the Stats prototype so the mock value still satisfies fsSync.Stats.
+function _fileStats(isLink: boolean): fsSync.Stats {
+ const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats
+ s.isSymbolicLink = () => isLink
+ s.isFile = () => !isLink
+ return s
+}
+
+// fs.BigIntStats is a type-only export (fs.BigIntStats is undefined at runtime), so the stand-in is
+// a Stats object carrying bigint ino/dev - exactly what fs.lstat(path, { bigint: true }) hands
+// back at runtime.
+function _fileStatsWithIdentity(ino: bigint, dev: bigint): fsSync.BigIntStats {
+ // Double assertion: the runtime value is the Stats stand-in, the type is the bigint variant.
+ const s = _fileStats(false) as unknown as fsSync.BigIntStats
+ s.ino = ino
+ s.dev = dev
+ return s
+}
+
+function mockDefaults(): void {
+ vi.resetAllMocks()
+ // After resetAllMocks, vi.fn() returns undefined — restore promise defaults.
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+ vi.mocked(fs.access).mockResolvedValue(undefined)
+ vi.mocked(fs.rename).mockResolvedValue(undefined)
+ vi.mocked(fs.unlink).mockResolvedValue(undefined)
+ vi.mocked(fs.rmdir).mockResolvedValue(undefined)
+ // Existing-target default: a regular 0o644 file.
+ vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644))
+ // Staged-file default: a regular file, not a link, so a caller-supplied
+ // tempPath passes the location and file-type check by default.
+ vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false))
+}
+function _stats(mode: number): fsSync.Stats {
+ const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats
+ Object.assign(s, { mode })
+ return s
+}
+
+// ── Test 1: staging file created then cleaned after success ────────────────
+
+describe("safeWriteText", () => {
+ beforeEach(() => {
+ mockDefaults()
+ // Default sync-write behaviour: report that all requested bytes were
+ // written. The Buffer overload passes (fd, buffer, offset, length),
+ // so the fourth argument is the requested length.
+ vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) =>
+ typeof args[3] === "number" ? args[3] : 0,
+ )
+ })
+
+ describe("staging and cleanup", () => {
+ it("creates a temp file in the staging dir, fsyncs it, renames to target, and cleans up on success", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1) // fd=1
+ vi.mocked(fsSync.closeSync).mockReturnValue(undefined)
+
+ await safeWriteText(targetPath, "hello world", { platform: "linux" })
+
+ // staging dir was created with private permissions — use
+ // stringContaining to handle Windows path resolution
+ expect(fsSync.mkdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), {
+ recursive: true,
+ mode: 0o700,
+ })
+ // a pre-existing staging dir is repaired to private permissions too
+ expect(fsSync.chmodSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), 0o700)
+
+ // temp file was opened for writing with the existing target's mode
+ // (default 0o644 from the statSync default mock)
+ expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644)
+
+ // content was written as a buffer (partial-write loop, full write)
+ expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("hello world", "utf8"), 0, 11)
+
+ // fsync (sync form) was called on the fd
+ expect(fsSync.fsyncSync).toHaveBeenCalledWith(1)
+
+ // file was closed
+ expect(fsSync.closeSync).toHaveBeenCalledWith(1)
+
+ // atomic rename happened — realpath mock returns targetPath, so that's the dest
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+
+ // no unlink of temp (it's now the committed file; DACL skipped via platform:linux)
+ expect(fs.unlink).not.toHaveBeenCalled()
+ })
+
+ it("removes the now-empty staging directory after a successful self-staged commit", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetPath, "hello", { platform: "linux" })
+
+ // the staging subdir is removed best-effort after the commit rename
+ // (stringContaining: the SUT and the test helper resolve Windows
+ // drive-relative paths differently, as in the existing staging tests)
+ expect(fs.rmdir).toHaveBeenCalledTimes(1)
+ expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"))
+ // the win32 DACL restore gate must stay closed on other platforms:
+ // no icacls save or restore is attempted
+ expect(execFile).not.toHaveBeenCalled()
+ })
+
+ it("still removes the staging directory when no options are supplied at all", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ // options is undefined: the self-staged check and the optional-chained
+ // DACL runner lookup must not dereference it
+ await expect(safeWriteText(targetPath, "hello")).resolves.toEqual({ leftoverPaths: [] })
+
+ expect(fs.rmdir).toHaveBeenCalledTimes(1)
+ expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"))
+ if (process.platform === "win32") {
+ // default platform is win32: the DACL save + restore still ran
+ // through the default icacls path (options?.execFileRunner must
+ // not throw when options is undefined)
+ expect(vi.mocked(execFile)).toHaveBeenCalledTimes(2)
+ expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl"))
+ }
+ })
+
+ it("does not remove the staging directory when the caller supplies its own tempPath", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ const callerTemp = "/tmp/test-dir/caller-staged.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp })
+
+ // the caller owns its temp file's directory; safeWriteText must not
+ // rmdir a directory it did not create
+ expect(fs.rmdir).not.toHaveBeenCalled()
+ })
+
+ it("a failed staging-dir removal never fails the committed write", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ vi.mocked(fs.rmdir).mockRejectedValue(Object.assign(new Error("ENOTEMPTY"), { code: "ENOTEMPTY" }))
+
+ const result = await safeWriteText(targetPath, "hello", { platform: "linux" })
+
+ // The commit rename still happened: a directory this write created is a leftover to clean
+ // up, not a failed save. It is reported rather than swallowed, because the caller is the
+ // only party that can sweep it and an empty list claims there is nothing left to sweep.
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath)
+ const stagingDir = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0]))[0]
+ expect(result).toEqual({ leftoverPaths: [stagingDir] })
+
+ // One retry: a directory can still be reported busy by the OS while the handle of the
+ // file it held is being released, so a single failure is not evidence it is stuck.
+ expect(fs.rmdir).toHaveBeenCalledTimes(2)
+ expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"))
+ })
+
+ it("gives each self-staged write its own staging directory so a concurrent write cannot remove it", async () => {
+ const targetA = "/tmp/test-dir/target-a.txt"
+ const targetB = "/tmp/test-dir/target-b.txt"
+ vi.mocked(fs.realpath).mockImplementation((p) => Promise.resolve(p as string))
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetA, "a", { platform: "linux" })
+ await safeWriteText(targetB, "b", { platform: "linux" })
+
+ // Two self-staged writes in the same directory must not share one staging
+ // directory: the first write's best-effort rmdir would otherwise delete the
+ // directory the second write had created but not yet opened (ENOENT on openSync).
+ const created = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0]))
+ const staging = created.filter((p) => p.includes(".file-safety-staging_"))
+ expect(staging).toHaveLength(2)
+ expect(staging[0]).not.toBe(staging[1])
+ // Uniqueness comes from the documented name shape
+ //
/.file-safety-staging__: pinning the shape
+ // keeps the separator and the random suffix meaningful, not just the prefix.
+ for (const dir of staging) {
+ expect(dir).toMatch(/\.file-safety-staging_\d+_[a-z0-9]+$/)
+ }
+ const removed = vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0]))
+ expect(removed).toEqual([staging[0], staging[1]])
+ })
+
+ it("removes its own staging directory when a self-staged write fails", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" }))
+
+ await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).rejects.toThrow("EACCES")
+
+ // The failed write's temp file is unlinked, then the directory it
+ // created is removed — a failed write must not leave an empty
+ // .file-safety-staging directory behind.
+ // mkdirSync created this write's staging directory; the temp file lives
+ // inside it, so the unlink targets a path under that directory.
+ const staging = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0]))
+ expect(staging).toHaveLength(1)
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(staging[0]))
+ expect(fs.rmdir).toHaveBeenCalledWith(staging[0])
+ // The directory is only empty after its temp file is gone, so the
+ // unlink must happen before the rmdir.
+ expect(vi.mocked(fs.unlink).mock.invocationCallOrder[0]).toBeLessThan(
+ vi.mocked(fs.rmdir).mock.invocationCallOrder[0],
+ )
+ })
+
+ it("does not remove a staging directory it did not create when a caller-staged write fails", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ const callerTemp = "/tmp/test-dir/caller-staged.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" }))
+
+ await expect(
+ safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp }),
+ ).rejects.toThrow("EACCES")
+
+ // The caller owns that directory: only the caller's temp file is cleaned,
+ // never a rmdir of a directory safeWriteText never created.
+ expect(fs.unlink).toHaveBeenCalledWith(callerTemp)
+ expect(fs.rmdir).not.toHaveBeenCalled()
+ })
+ })
+
+ it("win32: a rejecting async onWarning does not abort the write or leak an unhandled rejection", async () => {
+ // TypeScript accepts an async sink where a void callback is expected, so the
+ // wrapper has to attach a handler to the returned promise: an unhandled
+ // rejection can end the process under Node's default mode, after a write that
+ // already succeeded.
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // The DACL save succeeds: the notice this test needs has to come from a warning that
+ // still exists, and the DACL-save notice became a refusal.
+ vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => {
+ if (typeof cb === "function") cb(null, "", "")
+ return fakeChild
+ })
+ // The surviving notice is the leftover one, emitted after the commit.
+ vi.mocked(fs.unlink).mockImplementation(async (p) => {
+ if (String(p).includes("safeWriteText.bak_")) {
+ throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" })
+ }
+ })
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ await expect(
+ safeWriteText(targetPath, "data", {
+ backup: true,
+ platform: "win32",
+ onWarning: async () => {
+ throw new Error("async sink down")
+ },
+ }),
+ ).resolves.toEqual({ leftoverPaths: [expect.stringContaining("safeWriteText.bak_")] })
+
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath)
+ // The rejection is reported through the fallback sink rather than surfacing as an
+ // unhandled rejection.
+ expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("onWarning callback rejected"))
+ consoleWarn.mockRestore()
+ })
+
+ // ── Test 2: fsync ordering ───────────────────────────────────────────────
+
+ describe("fsync ordering", () => {
+ it("calls fsync on the fd before close, and rename after close", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetPath, "data", { platform: "linux" })
+
+ // Verify call order: openSync(temp) → writeSync → fsyncSync(temp)
+ // → closeSync(temp) → rename. On POSIX the parent directory is then
+ // opened and fsynced after the commit rename, so openSync/fsyncSync/
+ // closeSync each have a second (directory) call.
+ expect(vi.mocked(fsSync.openSync).mock.calls.length).toBe(2)
+ expect(vi.mocked(fsSync.writeSync).mock.calls.length).toBe(1)
+ expect(vi.mocked(fsSync.fsyncSync).mock.calls.length).toBe(2)
+ expect(vi.mocked(fsSync.closeSync).mock.calls.length).toBe(2)
+
+ // the temp file was fully closed before the commit rename
+ expect(vi.mocked(fsSync.closeSync).mock.calls[0][0]).toBe(1)
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+ // The title promises the order, so compare the invocations rather than
+ // only count them: a rename before closeSync, or a close before fsync,
+ // would not be a durable commit.
+ const fsyncOrder = vi.mocked(fsSync.fsyncSync).mock.invocationCallOrder[0]
+ const closeOrder = vi.mocked(fsSync.closeSync).mock.invocationCallOrder[0]
+ const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[0]
+ expect(fsyncOrder).toBeLessThan(closeOrder)
+ expect(closeOrder).toBeLessThan(renameOrder)
+ })
+ })
+
+ // ── Test 3: simulated failure between write and rename leaves target intact ──
+
+ describe("crash/torn-write safety", () => {
+ it("simulated failure between fsync and rename leaves the target byte-identical and no temp left behind", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC"))
+
+ await expect(safeWriteText(targetPath, "new data", { platform: "linux" })).rejects.toThrow("ENOSPC")
+
+ // rename was attempted (the failure point)
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+
+ // temp file was cleaned up on failure
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"))
+
+ // backup was NOT created (backup:false by default), so target is untouched
+ // The only rename call was temp→target, not a rollback rename
+ expect(fs.rename).toHaveBeenCalledTimes(1)
+ })
+
+ it("a post-commit backup cleanup failure is non-fatal: the target stays committed and no temp is left behind", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // The post-commit backup unlink (SUT step 6) fails — the write must
+ // still succeed; an orphaned backup is the documented acceptable
+ // outcome, so the failure is swallowed instead of rolling back.
+ vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EPERM"))
+
+ await safeWriteText(targetPath, "data", { backup: true, platform: "linux" })
+
+ // the commit rename (temp -> target) still happened; it is the only rename
+ expect(fs.rename).toHaveBeenCalledTimes(1)
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+
+ // the failing cleanup was the post-commit backup unlink
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"))
+
+ // the staging temp was already committed by the rename; nothing
+ // temp-shaped is unlinked afterwards
+ expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"))
+ })
+
+ it("a failed post-commit directory fsync does not roll the backup back over the published content", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ const dirPath = path.dirname(targetPath)
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ // The file fd opens normally; the parent-directory open after the commit
+ // rename fails, which is the post-commit durability failure.
+ vi.mocked(fsSync.openSync).mockImplementation((target) => {
+ if (String(target) === dirPath) throw new Error("EBADF")
+ return 1
+ })
+
+ await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow(
+ PostCommitDurabilityError,
+ )
+
+ // The commit rename already published the new content, and the backup was only
+ // ever a copy: the target was never moved, so there is nothing to rename back.
+ expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_"))
+ expect(fs.rename).toHaveBeenCalledTimes(1)
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+
+ // The durability failure is reported, not swallowed - and the backup copy is not
+ // left beside the target where no caller could find it.
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"))
+ })
+ })
+
+ // ── Test 4: backup:true keeps old safeWriteJson semantics, copy-based ──
+
+ describe("backup:true", () => {
+ it("copies target -> backup before commit without moving the target, deletes the copy on success", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetPath, "new data", { backup: true })
+
+ // target was accessed (exists check)
+ expect(fs.access).toHaveBeenCalledWith(targetPath)
+
+ // The backup is a copy: the canonical target is never moved away, so readers
+ // never see a missing file and no later step can clobber a concurrent publish.
+ expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_"))
+ expect(fs.rename).not.toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_"))
+
+ // the only rename is the atomic commit temp -> target
+ expect(fs.rename).toHaveBeenCalledTimes(1)
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+
+ // backup copy was deleted on success
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"))
+ })
+
+ it("a failed commit does not move the target, so nothing has to be rolled back", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // The commit rename is the only rename in the flow and it fails.
+ vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC"))
+
+ await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("ENOSPC")
+
+ // The target never left its path, so there is no restore rename and the
+ // pre-write content is still what a reader sees at targetPath.
+ expect(fs.rename).toHaveBeenCalledTimes(1)
+ expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_"))
+
+ // Both the backup copy and the staging temp are cleaned up on failure.
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"))
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"))
+ })
+
+ it("creates the backup privately before its content exists, then fsyncs it", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetPath, "new data", { backup: true })
+
+ // The destination must exist with a private mode before copyFile writes anything
+ // into it: copyFile chooses the destination mode itself, so a restrictive target
+ // could otherwise leave a group/world-readable copy that a later chmod cannot
+ // undo. "wx" also means a pre-existing path is never silently reused.
+ const seedOpen = vi.mocked(fsSync.openSync).mock.calls.find(function (call) {
+ return String(call[0]).includes("safeWriteText.bak_") && call[1] === "wx"
+ })
+ expect(seedOpen).toBeDefined()
+ expect(seedOpen?.[2]).toBe(0o600)
+ const seedOrder = vi.mocked(fsSync.openSync).mock.invocationCallOrder[
+ vi.mocked(fsSync.openSync).mock.calls.indexOf(seedOpen!)
+ ]
+ expect(seedOrder).toBeLessThan(vi.mocked(fs.copyFile).mock.invocationCallOrder[0])
+
+ // The chmod keeps a copied read-only attribute (Windows) from breaking the fsync
+ // open, and keeps a backup of a permissive file private.
+ expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_"))
+ expect(fs.chmod).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"), 0o600)
+ expect(vi.mocked(fs.chmod).mock.invocationCallOrder[0]).toBeGreaterThan(
+ vi.mocked(fs.copyFile).mock.invocationCallOrder[0],
+ )
+
+ // The copy is then opened for fsync with the writable flag.
+ const backupOpen = vi.mocked(fsSync.openSync).mock.calls.find(function (call) {
+ return String(call[0]).includes("safeWriteText.bak_") && call[1] === "r+"
+ })
+ expect(backupOpen).toBeDefined()
+ })
+
+ it("a failed backup flush is reported and leaves no partial backup behind", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // The copy lands, but the fsync of the copy fails: the retained content is not
+ // known to be durable, so the write must not proceed on a half-written backup.
+ // The staged temp is fsynced earlier with a different handle, so target the
+ // backup's fd specifically.
+ vi.mocked(fsSync.openSync).mockImplementation((p: unknown) =>
+ String(p).includes("safeWriteText.bak_") ? 7 : 1,
+ )
+ vi.mocked(fsSync.fsyncSync).mockImplementation((fd: unknown) => {
+ if (fd === 7) {
+ throw new Error("EIO")
+ }
+ })
+
+ await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow(
+ "EIO",
+ )
+
+ // Nothing was published, and the incomplete copy is removed rather than left
+ // next to the target looking like a usable backup.
+ expect(fs.rename).not.toHaveBeenCalled()
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"))
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"))
+ })
+
+ it("carries the leftover path when the partial backup cannot be removed either", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockImplementation((p: unknown) =>
+ String(p).includes("safeWriteText.bak_") ? 7 : 1,
+ )
+ vi.mocked(fsSync.fsyncSync).mockImplementation((fd: unknown) => {
+ if (fd === 7) {
+ throw new Error("EIO")
+ }
+ })
+ let unlinkAttempts = 0
+ vi.mocked(fs.unlink).mockImplementation(async (p: unknown) => {
+ if (String(p).includes("safeWriteText.bak_")) {
+ unlinkAttempts++
+ }
+ throw Object.assign(new Error("EPERM"), { code: "EPERM" })
+ })
+
+ // The copy failed and the locked leftover could not be unlinked even after the
+ // retry. Dropping the path would leave a partial copy of the previous content on
+ // disk with no reference to it anywhere, so the error carries it.
+ const error = await safeWriteText(targetPath, "new data", {
+ backup: true,
+ platform: "linux",
+ }).catch((caught: unknown) => caught)
+
+ expect(error).toBeInstanceOf(OrphanedBackupError)
+ const orphan = error as OrphanedBackupError
+ expect(orphan.orphanedBackupPath).toContain("safeWriteText.bak_")
+ expect(orphan.message).toContain("safeWriteText.bak_")
+ expect(orphan.message).toContain("EPERM")
+ expect(orphan.originalError).toBeInstanceOf(Error)
+ expect((orphan.originalError as Error).message).toBe("EIO")
+ expect(orphan.cause).toBe(orphan.originalError)
+ expect(unlinkAttempts).toBe(2)
+ expect(fs.rename).not.toHaveBeenCalled()
+
+ // The dedicated orphan field names the copy, and the residue lists every path this write
+ // left on disk: the partial copy first, then the staged file the failure handler could not
+ // remove either. The throw discards the structured result, so without these the caller
+ // would have to work out for itself which of its paths are still on disk.
+ expect(orphan).toMatchObject({
+ leftoverPaths: [
+ expect.stringContaining("safeWriteText.bak_"),
+ expect.stringContaining("safeWriteText_"),
+ ],
+ cleanupErrors: [expect.objectContaining({ code: "EPERM" }), expect.objectContaining({ code: "EPERM" })],
+ })
+ })
+
+ it("a failed staging-file flush rejects and publishes nothing", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // The staged file's own fsync fails. The bytes are not known to have reached the
+ // disk, so the commit rename must not happen at all - this is the durability gate
+ // the staging fsync exists for.
+ vi.mocked(fsSync.fsyncSync).mockImplementation(() => {
+ throw new Error("EIO")
+ })
+
+ await expect(safeWriteText(targetPath, "new data", { platform: "linux" })).rejects.toThrow("EIO")
+
+ expect(fs.rename).not.toHaveBeenCalled()
+ // The fd is closed despite the throw, and neither the staged file nor its private
+ // staging directory survives the failure.
+ expect(fsSync.closeSync).toHaveBeenCalledWith(1)
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"))
+ expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"))
+ })
+
+ it("retries a failed backup cleanup and removes the copy", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ const warnings: string[] = []
+ let backupUnlinkAttempts = 0
+ // Windows commonly reports EPERM for a file whose handle has not been released yet,
+ // so the first failure must not end the cleanup.
+ vi.mocked(fs.unlink).mockImplementation(async (p: unknown) => {
+ if (String(p).includes("safeWriteText.bak_")) {
+ backupUnlinkAttempts++
+ if (backupUnlinkAttempts === 1) {
+ throw Object.assign(new Error("EPERM"), { code: "EPERM" })
+ }
+ }
+ })
+
+ await safeWriteText(targetPath, "new data", {
+ backup: true,
+ platform: "linux",
+ onWarning: (message: string) => {
+ warnings.push(message)
+ },
+ })
+
+ expect(backupUnlinkAttempts).toBe(2)
+ expect(warnings).toHaveLength(0)
+ })
+
+ it("reports a backup it cannot remove instead of dropping the path", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ const warnings: string[] = []
+ let backupUnlinkAttempts = 0
+ vi.mocked(fs.unlink).mockImplementation(async (p: unknown) => {
+ if (String(p).includes("safeWriteText.bak_")) {
+ backupUnlinkAttempts++
+ throw Object.assign(new Error("EPERM"), { code: "EPERM" })
+ }
+ })
+
+ // The publish succeeded, so a leftover backup must not fail the write; but the copy
+ // of the previous content is still on disk, so its path has to be reported rather
+ // than dropped where no caller can act on it.
+ await safeWriteText(targetPath, "new data", {
+ backup: true,
+ platform: "linux",
+ onWarning: (message: string) => {
+ warnings.push(message)
+ },
+ })
+
+ expect(fs.rename).toHaveBeenCalledTimes(1)
+ expect(backupUnlinkAttempts).toBe(2)
+ expect(warnings).toHaveLength(1)
+ expect(warnings[0]).toContain("safeWriteText.bak_")
+ expect(warnings[0]).toContain("EPERM")
+ })
+
+ 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()
+ })
+ })
+
+ it("treats an already-absent post-commit backup as cleaned up, without a second unlink", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ const onWarning = vi.fn()
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ vi.mocked(fsSync.closeSync).mockReturnValue(undefined)
+ // Step 6 removes the backup copy after the commit; something else removed it first.
+ vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" }))
+
+ await safeWriteText(targetPath, "data", { backup: true, platform: "linux", onWarning })
+
+ // ENOENT means the cleanup goal is already met: the write resolves, nothing is reported
+ // as a leftover, and the retry loop stops instead of unlinking the same path twice.
+ const backupUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call) {
+ return String(call[0]).includes("safeWriteText.bak")
+ })
+ expect(backupUnlinks.length).toBe(1)
+ expect(onWarning).not.toHaveBeenCalled()
+ })
+
+ it("propagates a seed-descriptor close failure and runs the backup cleanup", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ // The seed open is the only "wx" open; give its descriptor a distinguishable fd.
+ vi.mocked(fsSync.openSync).mockImplementation(((p: fsSync.PathLike, flags?: fsSync.OpenMode) =>
+ flags === "wx" ? 42 : 1) as typeof fsSync.openSync)
+ let seedCloseAttempts = 0
+ vi.mocked(fsSync.closeSync).mockImplementation((fd: number) => {
+ if (fd === 42) {
+ seedCloseAttempts++
+ throw new Error("close failed")
+ }
+ return undefined
+ })
+
+ await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow(
+ "close failed",
+ )
+
+ // Exactly one close attempt: POSIX close(2) may have released the descriptor before it
+ // reported the error, so a retry could release a descriptor another operation reused.
+ expect(seedCloseAttempts).toBe(1)
+ // The seeded backup must not outlive the failed write.
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak"))
+ })
+
+ // ── 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 refuses the publish instead of renaming over the target", 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
+ })
+
+ // Captured once: calling safeWriteText twice here would double-count the single icacls
+ // attempt asserted below.
+ const refusal = await safeWriteText(targetPath, "data", { platform: "win32" }).catch(
+ (error: unknown) => error,
+ )
+
+ expect(refusal).toBeInstanceOf(DaclInspectionError)
+ // The class alone does not pin the contract: swapping the phase or dropping the target path
+ // still satisfies toBeInstanceOf, and both are caller-visible - the phase says which check
+ // refused, the path says which file the caller must not assume was saved.
+ expect(refusal).toMatchObject({
+ name: "DaclInspectionError",
+ phase: "save",
+ targetPath,
+ message: expect.stringContaining("its DACL could not be saved"),
+ })
+
+ // Contract change (Security Boundaries row): a target whose DACL could not be saved is
+ // no longer replaced by a file that inherits different rights. Nothing is committed.
+ expect(fs.rename).not.toHaveBeenCalled()
+ // one icacls attempt only: a failed DACL apply must not try to restore
+ expect(execFile).toHaveBeenCalledTimes(1)
+ })
+
+ it("win32: refuses the publish without an onWarning notice when the DACL cannot be saved", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => {
+ if (typeof cb === "function") cb(new Error("icacls error"), "", "")
+ return fakeChild
+ })
+ const warnings: string[] = []
+
+ await expect(
+ safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }),
+ ).rejects.toBeInstanceOf(DaclInspectionError)
+
+ // Contract change: the refusal replaces the warning. There is no replacement whose access
+ // rights could have changed, so no notice is emitted for a write that did not happen.
+ expect(fs.rename).not.toHaveBeenCalled()
+ expect(warnings).toHaveLength(0)
+ })
+
+ it("win32: refuses the publish when the target cannot be checked for DACL preservation", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // The target exists but is not readable: that is not "absent", and skipping DACL
+ // preservation has to be visible.
+ vi.mocked(fs.access).mockImplementation(async (p) => {
+ if (String(p) === targetPath) {
+ throw Object.assign(new Error("EACCES"), { code: "EACCES" })
+ }
+ })
+ const warnings: string[] = []
+
+ // Captured once: a second call would double-count the assertions below.
+ const refusal = await safeWriteText(targetPath, "data", {
+ platform: "win32",
+ onWarning: (m) => warnings.push(m),
+ }).catch((error: unknown) => error)
+
+ expect(refusal).toBeInstanceOf(DaclInspectionError)
+ // The class alone does not pin the contract: swapping the phase or dropping the target path
+ // still satisfies toBeInstanceOf, and both are caller-visible - the phase says which check
+ // refused, the path says which file the caller must not assume was saved.
+ expect(refusal).toMatchObject({
+ name: "DaclInspectionError",
+ phase: "inspect",
+ targetPath,
+ message: expect.stringContaining("its access rights could not be checked"),
+ })
+
+ // Contract change: "there but unreadable" is no longer a reason to publish blind. The
+ // publish is refused before any icacls runs, and nothing is warned about a write that did
+ // not happen.
+ expect(execFile).not.toHaveBeenCalled()
+ expect(fs.rename).not.toHaveBeenCalled()
+ expect(warnings).toHaveLength(0)
+ })
+
+ // The DACL refusals sit inside the try whose catch performs the cleanup, so a refused publish is
+ // cleaned up by that handler and by nothing else. Counted, not matched with toHaveBeenCalledWith:
+ // that matcher passes however many times the same path is passed, so it cannot see a cleanup
+ // that runs twice.
+ it("win32: a refused publish removes its staged file and staging directory exactly once", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => {
+ if (typeof cb === "function") cb(new Error("icacls error"), "", "")
+ return fakeChild
+ })
+
+ await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toBeInstanceOf(
+ DaclInspectionError,
+ )
+
+ const unlinkCalls = vi.mocked(fs.unlink).mock.calls.map((call) => String(call[0]))
+ const rmdirCalls = vi.mocked(fs.rmdir).mock.calls.map((call) => String(call[0]))
+ expect(unlinkCalls.filter((p) => p.includes("safeWriteText_"))).toHaveLength(1)
+ expect(rmdirCalls.filter((p) => p.includes(".file-safety-staging"))).toHaveLength(1)
+ })
+
+ // Warning delivery is advisory: it must not be able to fail the save it is reporting on.
+ it("win32: a throwing onWarning does not abort the write", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // The DACL save succeeds here: the warning this test exercises has to come from a notice
+ // that still exists, and the DACL-save notice became a refusal.
+ vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => {
+ if (typeof cb === "function") cb(null, "", "")
+ return fakeChild
+ })
+ // The surviving notice is the leftover one: the publish committed and the copy of the
+ // previous content could not be removed (Windows reports EPERM while a handle is open).
+ vi.mocked(fs.unlink).mockImplementation(async (p) => {
+ if (String(p).includes("safeWriteText.bak_")) {
+ throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" })
+ }
+ })
+
+ await expect(
+ safeWriteText(targetPath, "data", {
+ backup: true,
+ platform: "win32",
+ onWarning: () => {
+ throw new Error("callback down")
+ },
+ }),
+ ).resolves.toEqual({ leftoverPaths: [expect.stringContaining("safeWriteText.bak_")] })
+
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath)
+ })
+
+ it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // icacls save fails — a real icacls may have written a partial dump
+ // before erroring, so the dump path must be cleaned up and must never
+ // be used for a restore.
+ vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => {
+ if (typeof cb === "function") cb(new Error("icacls save error"), "", "")
+ return fakeChild
+ })
+
+ await expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toBeInstanceOf(
+ DaclInspectionError,
+ )
+
+ // Contract change: the publish is refused, so nothing is renamed. Only the save was
+ // attempted, and the partial dump is still removed below.
+ expect(fs.rename).not.toHaveBeenCalled()
+ expect(execFile).toHaveBeenCalledTimes(1)
+ const saveArgs = vi.mocked(execFile).mock.calls[0]?.[1]
+ expect(saveArgs?.[1]).toBe("/save")
+ // the dump path (possibly partially created by icacls) was unlinked
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl"))
+ })
+
+ 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("safeWriteText.acl"), "/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("safeWriteText.acl"),
+ ])
+
+ // dump file was unlinked after restore
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl"))
+ })
+
+ it("win32 DACL save runs before the backup copy, not after it", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ // The title is about order, so assert the order the mocks were actually
+ // called in. If the save ran after the backup copy the dump could describe a
+ // file that a concurrent publish had already replaced.
+ await safeWriteText(targetPath, "data", { backup: true, platform: "win32" })
+
+ const callOrder = vi.mocked(execFile).mock.invocationCallOrder
+ const copyOrder = vi.mocked(fs.copyFile).mock.invocationCallOrder
+ const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder
+ const saveCall = callOrder[0]
+ const restoreCall = callOrder[1]
+ const backupCopy = copyOrder[0]
+ const commitRename = renameOrder[0]
+
+ expect(saveCall).toBeLessThan(backupCopy)
+ expect(backupCopy).toBeLessThan(commitRename)
+ expect(commitRename).toBeLessThan(restoreCall)
+ })
+
+ it("win32 DACL: a failed restore is an error and the dump is still unlinked", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ // icacls save succeeds, restore fails
+ let callCount = 0
+ 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 expect(safeWriteText(targetPath, "data", { platform: "win32" })).rejects.toBeInstanceOf(
+ DaclRestoreError,
+ )
+
+ // The content did commit before the restore failed, so the rename happened exactly once
+ // even though the publish reports an error.
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+ 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("safeWriteText.acl"))
+ })
+
+ 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/test-dir/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/test-dir/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/test-dir/custom-temp.tmp"
+
+ await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" })
+
+ // no existing target, so nothing to preserve and no fchmod on the temp
+ expect(fsSync.fchmodSync).not.toHaveBeenCalled()
+ expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath)
+ })
+
+ it("propagates a non-ENOENT stat failure rather than defaulting the mode (caller-staged)", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })
+ vi.mocked(fsSync.statSync).mockImplementation(() => {
+ throw eacces
+ })
+ vi.mocked(fsSync.openSync).mockReturnValue(2)
+
+ const customTempPath = "/tmp/test-dir/custom-temp.tmp"
+
+ // A target that cannot be stat'd is not a fresh target: publishing with
+ // the default mode would widen a restrictive target through the rename.
+ await expect(
+ safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }),
+ ).rejects.toThrow("EACCES")
+ expect(fsSync.fchmodSync).not.toHaveBeenCalled()
+ expect(fs.rename).not.toHaveBeenCalled()
+ })
+
+ it("propagates a non-ENOENT stat failure rather than defaulting the mode (self-staged)", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ const eio = Object.assign(new Error("EIO: i/o error"), { code: "EIO" })
+ vi.mocked(fsSync.statSync).mockImplementation(() => {
+ throw eio
+ })
+
+ // The mode is read before the temp is opened, so a real I/O failure stops
+ // the write before anything is staged.
+ await expect(safeWriteText(targetPath, "hello world", { platform: "linux" })).rejects.toThrow("EIO")
+ expect(fsSync.openSync).not.toHaveBeenCalled()
+ expect(fs.rename).not.toHaveBeenCalled()
+ })
+
+ it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o444))
+ vi.mocked(fsSync.openSync).mockReturnValue(3)
+
+ const customTempPath = "/tmp/test-dir/custom-temp.tmp"
+
+ await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" })
+
+ // a 0o444 target must not make openSync(tempPath, "r+") fail: the mode
+ // is applied with fchmodSync on the already-open fd, after the open
+ expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+")
+ expect(fsSync.fchmodSync).toHaveBeenCalledWith(3, 0o444)
+ const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0]
+ const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0]
+ expect(openIdx).toBeLessThan(fchmodIdx)
+ expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath)
+ })
+
+ it("applies the existing target's exact mode to the self-staged temp (umask must not narrow it)", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o664))
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetPath, "data", { platform: "linux" })
+
+ // openSync's creation mode is narrowed by the process umask (0o664 -> 0o644 with
+ // the common 0o022), and the rename publishes the temp's mode onto the target,
+ // so the existing target's mode must be applied on the fd before the commit.
+ expect(fsSync.fchmodSync).toHaveBeenCalledWith(1, 0o664)
+ const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0]
+ const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0]
+ expect(openIdx).toBeLessThan(fchmodIdx)
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+ })
+
+ it("does not fchmod the self-staged temp for a fresh target", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })
+ vi.mocked(fsSync.statSync).mockImplementation(() => {
+ throw enoent
+ })
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetPath, "data", { platform: "linux" })
+
+ // Nothing exists to preserve: the default creation mode is the intended one.
+ expect(fsSync.fchmodSync).not.toHaveBeenCalled()
+ })
+ })
+
+ // ── Test 7: symlink handling (Finding 4 regression test) ─────────────────
+
+ describe("symlink handling", () => {
+ it("a write through a symlink commits onto the resolved referent, never the link path", async () => {
+ const linkPath = "/tmp/links/link.txt"
+ const referentPath = "/tmp/targets/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(referentPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(linkPath, "new-content", { platform: "linux" })
+
+ // The commit rename must target the realpath result (the referent), never the link itself —
+ // that is what guarantees a write through a symlink replaces the referent's content
+ // and preserves the link.
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), referentPath)
+ expect(fs.rename).not.toHaveBeenCalledWith(expect.anything(), linkPath)
+ })
+
+ it("when realpath reports ENOENT (target absent), uses the given path as-is", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }))
+ // lstat reports the path itself as absent, so this is a new target and
+ // the fallback is allowed.
+ vi.mocked(fs.lstat).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }))
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetPath, "data", { platform: "linux" })
+
+ // rename still happened with the fallback path (path.resolve on /tmp → C:\tmp)
+ const resolvedFallback = _resolvedTarget(targetPath)
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), resolvedFallback)
+ })
+
+ it("propagates a dangling symlink instead of writing through the link path", async () => {
+ // realpath resolves the referent, so a link whose target is missing reports
+ // ENOENT. Falling back to the link path would replace the symlink with a
+ // regular file, so the error must propagate and nothing may be committed.
+ const linkPath = "/tmp/test-dir/dangling-link.txt"
+ vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }))
+ const linkStats = Object.create(fsSync.Stats.prototype) as fsSync.Stats
+ linkStats.isSymbolicLink = () => true
+ vi.mocked(fs.lstat).mockResolvedValue(linkStats)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await expect(safeWriteText(linkPath, "data", { platform: "linux" })).rejects.toThrow("ENOENT")
+
+ expect(fs.rename).not.toHaveBeenCalled()
+ })
+ })
+
+ // ── Test 8: review fixes (permissions, partial writes, resolution, durability) ──
+
+ describe("review fixes", () => {
+ it("preserves the target's restrictive mode and tolerates a failed staging-dir permission repair", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600))
+ // a pre-existing staging dir may fail its best-effort permission repair
+ vi.mocked(fsSync.chmodSync).mockImplementationOnce(() => {
+ throw new Error("EACCES")
+ })
+
+ await safeWriteText(targetPath, "secret", { platform: "linux" })
+
+ // the staging file inherits the target's 0o600 mode and the write commits
+ expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o600)
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+ })
+
+ it("falls back to the 0o644 default when the target does not exist yet", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ vi.mocked(fsSync.statSync).mockImplementation(() => {
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" })
+ })
+
+ await safeWriteText(targetPath, "fresh", { platform: "linux" })
+
+ expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644)
+ })
+
+ it("loops on short writes until the full content is durable before fsync", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ const content = "0123456789" // 10 bytes
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ const buffer = Buffer.from(content, "utf8")
+ // first write (offset 0) reports 4 bytes (short write); the loop continues
+ vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) =>
+ args[2] === 0 ? 4 : typeof args[3] === "number" ? args[3] : 0,
+ )
+
+ await safeWriteText(targetPath, content, { platform: "linux" })
+
+ // [0,10) reports 4 bytes, then [4,10) writes the remaining 6
+ expect(fsSync.writeSync).toHaveBeenCalledTimes(2)
+ expect(fsSync.writeSync).toHaveBeenNthCalledWith(1, 1, buffer, 0, 10)
+ expect(fsSync.writeSync).toHaveBeenNthCalledWith(2, 1, buffer, 4, 6)
+ expect(fsSync.fsyncSync).toHaveBeenCalledWith(1)
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+ })
+
+ it("fsyncs the parent directory after the commit rename on POSIX", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ // temp fd=1 then parent-dir fd=2 - distinct fds prove the ordering
+ vi.mocked(fsSync.openSync).mockReturnValueOnce(1).mockReturnValue(2)
+
+ await safeWriteText(targetPath, "data", { platform: "linux" })
+
+ // the directory fsync (fd 2) happens only after the file fsync (fd 1);
+ // the dir path assertion is path-agnostic (stringContaining) because
+ // path.dirname renders the same input differently on Windows
+ expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("test-dir"), "r")
+ expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(1, 1)
+ expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(2, 2)
+ expect(fsSync.closeSync).toHaveBeenCalledWith(2)
+ })
+
+ it("reports a failed parent-directory fsync instead of claiming a durable write", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync)
+ .mockReturnValueOnce(1)
+ .mockImplementationOnce(() => {
+ throw new Error("EBADF")
+ })
+
+ // The content rename committed, so the caller can still find the data at
+ // the target; what the write cannot claim is that the directory entry
+ // reached the disk. Returning success here would claim durability the
+ // filesystem did not grant.
+ await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toThrow(
+ PostCommitDurabilityError,
+ )
+
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+ })
+
+ it("propagates realpath errors (EACCES and code-less) instead of the fallback path", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })
+ vi.mocked(fs.realpath).mockRejectedValueOnce(eacces)
+ await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces)
+ expect(fs.rename).not.toHaveBeenCalled()
+
+ const plain = new Error("resolution failed")
+ vi.mocked(fs.realpath).mockRejectedValueOnce(plain)
+ await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(plain)
+ expect(fs.rename).not.toHaveBeenCalled()
+ })
+
+ it("backup:true propagates access errors (EACCES and code-less) instead of skipping the backup", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" })
+ const plain = new Error("access failed")
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // each write accesses dirPath then target; only the target access rejects
+ const rejectTarget = (error: Error) => async (p: unknown) => {
+ if (typeof p === "string" && p.endsWith("target.txt")) throw error
+ }
+ vi.mocked(fs.access)
+ .mockImplementationOnce(rejectTarget(eacces))
+ .mockImplementationOnce(rejectTarget(eacces))
+ .mockImplementationOnce(rejectTarget(plain))
+ .mockImplementationOnce(rejectTarget(plain))
+
+ await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toEqual(
+ expect.objectContaining({ code: "EACCES" }),
+ )
+ await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow(
+ "access failed",
+ )
+ expect(fs.rename).not.toHaveBeenCalled()
+ })
+ })
+ describe("content bytes", () => {
+ const targetPath = "/tmp/enc-dir/target.txt"
+
+ beforeEach(() => {
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ })
+
+ it("stages UTF-8 bytes for string content", async () => {
+ await safeWriteText(targetPath, "héllo", { platform: "linux" })
+ expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("héllo", "utf8"), 0, 6)
+ })
+
+ it("publishes caller-supplied bytes unchanged instead of re-encoding them", async () => {
+ // The extension host encodes a document with VS Code's own codec, which
+ // covers the legacy code pages and BOMs Node cannot represent, and hands
+ // the result over: those bytes must reach the commit rename exactly as
+ // they were given.
+ const bytes = Buffer.from([0x00, 0x68, 0x00, 0x69])
+ await safeWriteText(targetPath, bytes, { platform: "linux" })
+ expect(fsSync.writeSync).toHaveBeenCalledWith(1, bytes, 0, 4)
+ })
+ })
+})
+
+// ── Test 12: lock key, staging path, and post-commit durability ─────────────
+
+describe("resolveLockKey", () => {
+ beforeEach(() => mockDefaults())
+
+ it("canonicalizes the parent directory, not just the file", async () => {
+ vi.mocked(fs.realpath).mockImplementation(async (target) => {
+ const key = String(target)
+ if (key === "/tmp/linkdir/file.json") return "/real/dir/file.json"
+ if (key === "/real/dir") return "/real/dir"
+ return key
+ })
+
+ // The key is the canonical directory plus the basename, so a symlinked
+ // ancestor and its referent share one lock.
+ await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe(path.join("/real/dir", "file.json"))
+ })
+
+ it("computes a key for a dangling link, which resolvePublishTarget refuses", async () => {
+ const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })
+ vi.mocked(fs.realpath).mockRejectedValue(enoent)
+ vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true))
+ // Only the link path is read, so a single answer is enough and keeps the mock's
+ // return type matching fs.promises.readlink.
+ vi.mocked(fs.readlink).mockResolvedValue("referent.json")
+
+ // Mid-commit a peer writer unlinks the referent and re-creates it, so the key
+ // must still be computable while the link dangles.
+ await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe(
+ path.resolve(path.join("/tmp/linkdir", "referent.json")),
+ )
+ })
+
+ it("terminates on a two-link cycle instead of walking forever", async () => {
+ const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })
+ vi.mocked(fs.realpath).mockRejectedValue(enoent)
+ vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true))
+ // Every readlink answers with the same link, so an unbounded walk would
+ // never end; the bounded walk returns the key it actually reached.
+ vi.mocked(fs.readlink).mockImplementation(async () => "a.json")
+
+ await expect(resolveLockKey("/tmp/linkdir/a.json")).resolves.toBe(
+ path.resolve(path.join("/tmp/linkdir", "a.json")),
+ )
+ expect(fs.readlink).toHaveBeenCalledTimes(8)
+ })
+})
+
+describe("caller-supplied staging path", () => {
+ beforeEach(() => mockDefaults())
+
+ it("rejects a staging file outside the target's directory before writing anything", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+
+ // A rename across filesystems fails with EXDEV, and a path elsewhere lets
+ // a caller publish an unrelated file onto the target.
+ await expect(
+ safeWriteText(targetPath, "data", { tempPath: "/tmp/other-dir/x.tmp", platform: "linux" }),
+ ).rejects.toThrow(StagingPathError)
+ expect(fsSync.openSync).not.toHaveBeenCalled()
+ expect(fs.rename).not.toHaveBeenCalled()
+ })
+
+ it("rejects a staging path that is a symlink", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true))
+
+ // Renaming a link over the target publishes whatever the link points at.
+ await expect(
+ safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/x.tmp", platform: "linux" }),
+ ).rejects.toThrow(StagingPathError)
+ expect(fsSync.openSync).not.toHaveBeenCalled()
+ expect(fs.rename).not.toHaveBeenCalled()
+ })
+
+ it("rejects a staging path that is a directory rather than a regular file", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ // A directory satisfies the location rule (it sits beside the target) and is neither a
+ // symlink nor a regular file. Renaming it over the target would publish a directory in
+ // place of the file, so the rejection has to name the file type and happen before anything
+ // is opened or renamed. Structural double: BigIntStats has no constructor a test can call.
+ vi.mocked(fs.lstat).mockResolvedValue({
+ isSymbolicLink: () => false,
+ isFile: () => false,
+ } as unknown as BigIntStats)
+
+ await expect(
+ safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/x.tmp", platform: "linux" }),
+ ).rejects.toThrow(/another file type/)
+
+ expect(fsSync.openSync).not.toHaveBeenCalled()
+ expect(fs.rename).not.toHaveBeenCalled()
+ })
+
+ it("rejects a staging path that is the target itself", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ // Same inode and device for the supplied staging path and the target: the
+ // failure handler would unlink the only copy of the content, so a failed
+ // write would delete the file it was meant to protect.
+ const stats = _fileStatsWithIdentity(42n, 7n)
+ vi.mocked(fs.lstat).mockResolvedValue(stats)
+
+ await expect(safeWriteText(targetPath, "data", { tempPath: targetPath, platform: "linux" })).rejects.toThrow(
+ StagingPathError,
+ )
+ expect(fsSync.openSync).not.toHaveBeenCalled()
+ expect(fs.rename).not.toHaveBeenCalled()
+ // The comparison is only sound when both stats are read as bigint: on NTFS/ReFS the file
+ // identifiers exceed Number.MAX_SAFE_INTEGER.
+ // Filter on the options, not the spelling: path.resolve prefixes a drive letter on Windows,
+ // so the two identity reads are the calls that asked for options at all.
+ const identityLookups = vi.mocked(fs.lstat).mock.calls.filter((c) => c[1] !== undefined)
+ expect(identityLookups.length).toBeGreaterThanOrEqual(2)
+ for (const c of identityLookups) {
+ expect(c[1]).toEqual({ bigint: true })
+ }
+ expect(fs.unlink).not.toHaveBeenCalled()
+ })
+
+ it("rejects when the target identity cannot be compared for a reason other than a missing target", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ // A hard-linked staging file shares the target's inode, so the identity comparison is the only thing
+ // between this write and a rename onto the very file the guard protects. An EACCES from the target
+ // lstat must not be mistaken for "there is no target".
+ const stagingStats = _fileStatsWithIdentity(42n, 7n)
+ vi.mocked(fs.lstat).mockImplementation(async (p) => {
+ if (String(p) === targetPath) {
+ throw Object.assign(new Error("EACCES"), { code: "EACCES" })
+ }
+ return stagingStats
+ })
+
+ await expect(
+ safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/hardlink.txt", platform: "linux" }),
+ ).rejects.toThrow("Staging file could not be compared with the target")
+ expect(fsSync.openSync).not.toHaveBeenCalled()
+ expect(fs.rename).not.toHaveBeenCalled()
+ // Both identity reads must ask for bigint stats, or the comparison silently falls
+ // back to rounded numbers on NTFS/ReFS.
+ const identityLookups = vi.mocked(fs.lstat).mock.calls.filter((c) => c[1] !== undefined)
+ expect(identityLookups).toHaveLength(2)
+ for (const c of identityLookups) {
+ expect(c[1]).toEqual({ bigint: true })
+ }
+ })
+})
+
+describe("cleanup when a backed-up write fails before commit", () => {
+ beforeEach(() => mockDefaults())
+
+ it("releases the staged file, its copy and its own staging directory before throwing", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // The commit rename is the only rename in this flow and it fails.
+ vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC"))
+
+ await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow("ENOSPC")
+
+ // The staging file and this write's own directory must not leak, and neither may
+ // the backup copy: the target still holds the pre-write content on disk.
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"))
+ expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"))
+ const stagingDirs = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0]))
+ expect(stagingDirs.length).toBe(1)
+ expect(fs.rmdir).toHaveBeenCalledWith(stagingDirs[0])
+
+ const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[0]
+ const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0]
+ const rmdirOrder = vi.mocked(fs.rmdir).mock.invocationCallOrder[0]
+ expect(unlinkOrder).toBeGreaterThan(failingRenameOrder)
+ expect(rmdirOrder).toBeGreaterThan(failingRenameOrder)
+ })
+})
+
+describe("resolvePublishTarget", () => {
+ beforeEach(() => mockDefaults())
+
+ it("propagates an lstat failure that is not ENOENT instead of falling back to the link path", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })
+ const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })
+ vi.mocked(fs.realpath).mockRejectedValue(enoent)
+ vi.mocked(fs.lstat).mockRejectedValue(eacces)
+
+ // A failed lstat says nothing about whether the path is a link, so the
+ // fallback would publish through a link we were not allowed to inspect.
+ await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces)
+ expect(fs.rename).not.toHaveBeenCalled()
+ })
+
+ it("still falls back to the given path when lstat also reports the path as absent", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })
+ vi.mocked(fs.realpath).mockRejectedValue(enoent)
+ vi.mocked(fs.lstat).mockRejectedValue(enoent)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+
+ await safeWriteText(targetPath, "data", { platform: "linux" })
+
+ // The fallback is the resolved path, not the string that was handed in.
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), path.resolve(targetPath))
+ })
+})
+
+// A publish that cannot remove something it created has to say so on whichever exit it takes: the
+// resolved result carries the paths, and a rejection carries the same paths on the error, because the
+// throw discards the result. Each test below names one residue kind and one exit path.
+
+describe("residue a publish must not lose", () => {
+ beforeEach(() => mockDefaults())
+
+ /** Make only the DACL dump unlink fail, and record every attempt at it. */
+ function stuckDumpUnlink(): string[] {
+ const attempts: string[] = []
+ vi.mocked(fs.unlink).mockImplementation(async (p: unknown) => {
+ if (String(p).includes("safeWriteText.acl")) {
+ attempts.push(String(p))
+ throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" })
+ }
+ })
+ return attempts
+ }
+
+ it("win32 DACL: a dump the finally block cannot remove is reported on the resolved write", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ const dumpAttempts = stuckDumpUnlink()
+
+ const result = await safeWriteText(targetPath, "data", { platform: "win32" })
+
+ // The publish committed and the restore ran: a dump that survives cleanup is a leftover to
+ // sweep, not a failed save.
+ expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath)
+ expect(execFile).toHaveBeenCalledTimes(2)
+
+ // The dump is retried once, and the result names the path once: recording each attempt would
+ // tell the caller there are two files to remove when there is one.
+ expect(dumpAttempts).toHaveLength(2)
+ expect(result).toEqual({ leftoverPaths: [dumpAttempts[0]] })
+ })
+
+ it("win32 DACL: a partial dump that cannot be removed is reported on the refusal", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // icacls /save fails, so a partial dump may exist, and removing it fails too.
+ vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => {
+ if (typeof cb === "function") cb(new Error("icacls error"), "", "")
+ return fakeChild
+ })
+ const dumpAttempts = stuckDumpUnlink()
+
+ const refusal = await safeWriteText(targetPath, "data", { platform: "win32" }).catch(
+ (caught: unknown) => caught,
+ )
+
+ expect(refusal).toBeInstanceOf(DaclInspectionError)
+ expect(fs.rename).not.toHaveBeenCalled()
+
+ // The refusal throws, so the structured result never reaches the caller. Without the residue
+ // on the error the caller holds an error naming no file while a partial DACL dump - a file
+ // whose contents describe access rights - stays on disk with no reference to it anywhere.
+ expect(refusal).toMatchObject({
+ leftoverPaths: [dumpAttempts[0]],
+ cleanupErrors: [expect.objectContaining({ code: "EPERM" })],
+ })
+ })
+
+ it("win32 DACL: a restore failure carries the dump its cleanup could not remove", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // The save lands, the restore fails: the commit happened, so this is the exit where the
+ // dump's owner is the finally block and the error is DaclRestoreError.
+ let icaclsCalls = 0
+ vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => {
+ icaclsCalls++
+ if (typeof cb === "function") cb(icaclsCalls === 1 ? null : new Error("icacls restore error"), "", "")
+ return fakeChild
+ })
+ const dumpAttempts = stuckDumpUnlink()
+
+ const failure = await safeWriteText(targetPath, "data", { platform: "win32" }).catch(
+ (caught: unknown) => caught,
+ )
+
+ expect(failure).toBeInstanceOf(DaclRestoreError)
+ expect(fs.rename).toHaveBeenCalledTimes(1)
+ // The original error is preserved, not replaced: its message is what tells the caller that
+ // the committed file answers to different access rights.
+ expect((failure as DaclRestoreError).message).toContain("could not be restored")
+
+ // Previously the finally block recorded this path and the failure handler threw it away with
+ // the result, so a dump left by a failed restore was invisible on the only exit taken.
+ expect(failure).toMatchObject({ leftoverPaths: [dumpAttempts[0]] })
+ })
+
+ it("a failed publish reports the staged file it could not remove", 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"))
+ const stagedAttempts: string[] = []
+ vi.mocked(fs.unlink).mockImplementation(async (p: unknown) => {
+ if (String(p).includes("safeWriteText_")) {
+ stagedAttempts.push(String(p))
+ throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" })
+ }
+ })
+
+ const failure = await safeWriteText(targetPath, "data", { platform: "linux" }).catch(
+ (caught: unknown) => caught,
+ )
+
+ expect((failure as Error).message).toBe("ENOSPC")
+ expect(stagedAttempts).toHaveLength(2)
+ expect(failure).toMatchObject({ leftoverPaths: [stagedAttempts[0]] })
+ })
+
+ it("a failed publish reports the staging directory it could not remove", 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"))
+ vi.mocked(fs.rmdir).mockRejectedValue(Object.assign(new Error("ENOTEMPTY"), { code: "ENOTEMPTY" }))
+
+ const failure = await safeWriteText(targetPath, "data", { platform: "linux" }).catch(
+ (caught: unknown) => caught,
+ )
+
+ const stagingDir = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0]))[0]
+ expect(failure).toMatchObject({ leftoverPaths: [stagingDir] })
+ })
+
+ it("a failed publish reports a backup copy it could not remove and still warns about it", 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"))
+ const backupAttempts: string[] = []
+ vi.mocked(fs.unlink).mockImplementation(async (p: unknown) => {
+ if (String(p).includes("safeWriteText.bak_")) {
+ backupAttempts.push(String(p))
+ throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" })
+ }
+ })
+ const warnings: string[] = []
+
+ const failure = await safeWriteText(targetPath, "data", {
+ backup: true,
+ platform: "linux",
+ onWarning: (message) => warnings.push(message),
+ }).catch((caught: unknown) => caught)
+
+ expect((failure as Error).message).toBe("ENOSPC")
+ // The human notice and the caller-visible list are separate channels: neither replaces the
+ // other, because a caller cannot parse a warning and a human cannot read a thrown array.
+ expect(warnings).toHaveLength(1)
+ expect(warnings[0]).toContain("safeWriteText.bak_")
+ expect(failure).toMatchObject({ leftoverPaths: [backupAttempts[0]] })
+ })
+
+ it("a publish that cleaned everything up attaches no residue to its error", 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"))
+
+ const failure = await safeWriteText(targetPath, "data", { platform: "linux" }).catch(
+ (caught: unknown) => caught,
+ )
+
+ // An empty list would make every caller ask whether "no leftovers" means it checked or that
+ // the field was never filled in, so nothing is attached unless something stayed on disk.
+ expect((failure as PublishResidue).leftoverPaths).toBeUndefined()
+ expect((failure as PublishResidue).cleanupErrors).toBeUndefined()
+ })
+
+ it("a thrown value that cannot carry the residue is rethrown exactly as it came", async () => {
+ const targetPath = "/tmp/test-dir/target.txt"
+ vi.mocked(fs.realpath).mockResolvedValue(targetPath)
+ vi.mocked(fsSync.openSync).mockReturnValue(1)
+ // A rejected promise can carry any value, not only an Error. Attaching the residue by
+ // replacing such a value would change the error the caller catches, and assigning a property
+ // to a primitive throws, so the failure would become a TypeError about the cleanup.
+ vi.mocked(fs.rename).mockRejectedValue("boom")
+ vi.mocked(fs.unlink).mockRejectedValue(Object.assign(new Error("EPERM"), { code: "EPERM" }))
+
+ const failure = await safeWriteText(targetPath, "data", { platform: "linux" }).catch(
+ (caught: unknown) => caught,
+ )
+
+ expect(failure).toBe("boom")
+ })
+})
diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts
new file mode 100644
index 0000000000..17bfd08059
--- /dev/null
+++ b/src/services/file-safety/safeWriteText.ts
@@ -0,0 +1,779 @@
+import * as fs from "fs/promises"
+import * as fsSync from "fs"
+import * as path from "path"
+import { execFile } from "child_process"
+
+export interface SafeWriteTextOptions {
+ /**
+ * When true, keep the old-file semantics without ever removing the target: the
+ * previous content is copied to a hidden backup path and flushed before the
+ * commit rename, the commit rename atomically replaces the target, and on success
+ * the backup copy is deleted. A failure before the commit leaves the target
+ * untouched (there is nothing to roll back) and removes the backup copy. When
+ * false (default) the atomic rename simply replaces the target.
+ */
+ backup?: boolean
+
+ /**
+ * 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
+
+ /**
+ * Sink for non-fatal safety notices. The notices that still reach it are the leftover ones: a copy
+ * of the previous content this write could not remove, reported with its path whether the
+ * publish committed or failed. A Windows DACL that could not be captured is no longer a notice
+ * here - the publish is refused with DaclInspectionError before anything is committed - and a
+ * saved DACL that could not be put back after the commit arrives as DaclRestoreError rather
+ * than a warning, so neither reaches this sink. Defaults to console.warn.
+ */
+ onWarning?: (message: string) => void
+
+ /**
+ * Pre-written temp path to use for the commit phase. When provided,
+ * safeWriteText skips creating its own staging file and uses this path
+ * instead (it still fsyncs before rename). Useful when a caller has
+ * already written data to a temp file via a custom stream.
+ */
+ tempPath?: string
+}
+
+/**
+ * A caller-supplied staging path that is not a file this write may publish: it
+ * sits outside the target's directory (so the commit rename would cross
+ * filesystems) or is not a regular file. Rejecting it before any write keeps the
+ * target from being replaced by whatever the path points at.
+ */
+export class StagingPathError extends Error {
+ readonly stagingPath: string
+
+ constructor(message: string, stagingPath: string) {
+ super(message)
+ this.name = "StagingPathError"
+ this.stagingPath = stagingPath
+ }
+}
+
+/**
+ * The commit rename succeeded but the parent-directory fsync did not, so the
+ * directory entry is not known to be durable. The content is at the target; the
+ * caller cannot assume it survives a crash. Reported as its own error so a
+ * successful return never claims durability the filesystem did not grant.
+ */
+export class PostCommitDurabilityError extends Error {
+ readonly targetPath: string
+
+ constructor(targetPath: string, cause: unknown) {
+ super(
+ "The rename committed but the parent directory could not be fsynced -- the content is at the target path reported on this error, and the directory entry may not be durable.",
+ { cause },
+ )
+ this.name = "PostCommitDurabilityError"
+ this.targetPath = targetPath
+ }
+}
+
+/**
+ * The target exists but its access rights could not be inspected or saved, so publishing would
+ * replace it with a file that inherits different rights. On Windows the publish fails instead of
+ * warning: a successful write that silently widened who can read the file is not a save the user
+ * can trust, and the failure happens before the commit, so the target still holds its content.
+ */
+export class DaclInspectionError extends Error {
+ constructor(
+ readonly targetPath: string,
+ readonly phase: "inspect" | "save",
+ readonly causeError: unknown,
+ ) {
+ const detail = phase === "save" ? "its DACL could not be saved" : "its access rights could not be checked"
+ super(`safeWriteText: refusing to publish over ${targetPath} because ${detail}`)
+ this.name = "DaclInspectionError"
+ }
+}
+
+/**
+ * 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.
+ */
+export class DaclRestoreError extends Error {
+ constructor(
+ readonly targetPath: string,
+ readonly causeError: unknown,
+ ) {
+ super(`safeWriteText: content committed at ${targetPath}, but its saved access rights could not be restored`)
+ this.name = "DaclRestoreError"
+ }
+}
+
+/** The outcome of a publish: paths this call left on disk that it could not remove. */
+export interface SafeWriteTextResult {
+ /**
+ * Every leftover this write could not clean up (a backup copy whose unlink kept failing, a DACL
+ * dump, a staging directory). A warning tells a human about them; this is what the caller has to
+ * act on - a retry, a startup sweep, or a message that names the path.
+ */
+ leftoverPaths: string[]
+}
+
+/**
+ * The residue a publish could not remove. A resolved write reports it in `SafeWriteTextResult`; a
+ * rejected write cannot, because the throw discards that result, so the same two lists travel on the
+ * error the caller catches instead. Without them a failed publish would leave a file on disk with no
+ * reference to it anywhere the caller can reach.
+ */
+export interface PublishResidue {
+ /** Paths this write left on disk that its own cleanup could not remove. */
+ leftoverPaths: string[]
+ /** The failure that kept each path on disk, in the same order as `leftoverPaths`. */
+ cleanupErrors: unknown[]
+}
+
+/**
+ * The backup copy could not be created AND the partial copy could not be removed.
+ * The write failed either way, but the leftover is a copy of the previous content that
+ * is still on disk: its path travels on the error so the caller can remove it, instead
+ * of the cleanup silently discarding the only reference to it.
+ */
+export class OrphanedBackupError extends Error {
+ readonly orphanedBackupPath: string
+ readonly originalError: unknown
+ readonly cleanupError: unknown
+ constructor(backupPath: string, targetPath: string, cause: unknown, cleanupError: unknown) {
+ super(_orphanedBackupMessage(targetPath, backupPath, cause, cleanupError), { cause })
+ this.name = "OrphanedBackupError"
+ this.orphanedBackupPath = backupPath
+ this.originalError = cause
+ this.cleanupError = cleanupError
+ }
+}
+
+function _orphanedBackupMessage(targetPath: string, backupPath: string, cause: unknown, cleanupError: unknown): string {
+ const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error))
+ return (
+ `safeWriteText: could not create the backup of ${targetPath} (${reason(cause)}), and the ` +
+ `partial copy at ${backupPath} could not be removed (${reason(cleanupError)}). The copy is ` +
+ `still on disk and must be deleted.`
+ )
+}
+// -- helpers ---------------------------------------------------------------
+
+/** Generate a unique temp file name in the given directory. */
+function _tempName(dir: string, prefix: string): string {
+ return path.join(dir, "." + prefix + "_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp")
+}
+
+/** Create a private per-write staging sub-directory inside *dir*. The name is
+ * unique per write, so concurrent writes never collide on their temp names and
+ * never remove a staging directory another write is still using: with one shared
+ * name, one write's best-effort rmdir could delete the directory another write
+ * had just created but not yet opened, failing its openSync with ENOENT. */
+function _stagingDir(dir: string): string {
+ const sd = path.join(dir, ".file-safety-staging_" + Date.now() + "_" + Math.random().toString(36).substring(2))
+ // mode:0o700 protects a freshly created staging dir; the best-effort chmod
+ // repairs a pre-existing one (mkdirSync with recursive:true never chmods an
+ // existing directory), so staged temp files are never group/world readable.
+ fsSync.mkdirSync(sd, { recursive: true, mode: 0o700 })
+ try {
+ fsSync.chmodSync(sd, 0o700)
+ } catch {
+ // best-effort: chmod denied or unavailable; a fresh dir was still
+ // created with the requested mode
+ }
+ return sd
+}
+
+function _fsyncFile(fd: number): void {
+ fsSync.fsyncSync(fd)
+}
+
+/** Save the DACL of *srcPath* to a dump file on Windows.
+ * Returns true when the dump was written successfully; false otherwise.
+ * Never throws — callers treat failure as "skip DACL handling". */
+async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise {
+ const runner = execFileRunner ?? execFile
+ try {
+ await new Promise((resolve, reject) => {
+ runner("icacls", [srcPath, "/save", dumpPath, "/T"], { windowsHide: true }, (err) =>
+ err ? reject(err) : resolve(),
+ )
+ })
+ return true
+ } catch {
+ return false
+ }
+}
+
+/** Restore a DACL dump onto *dirPath* on Windows.
+ * Returns whether icacls succeeded; the caller reports a failure. */
+async function _restoreDaclWindows(
+ dirPath: string,
+ dumpPath: string,
+ execFileRunner?: typeof execFile,
+): Promise {
+ const runner = execFileRunner ?? execFile
+ try {
+ await new Promise((resolve, reject) => {
+ runner("icacls", [dirPath, "/restore", dumpPath], { windowsHide: true }, (err) =>
+ err ? reject(err) : resolve(),
+ )
+ })
+ return true
+ } catch {
+ return false
+ }
+}
+
+// -- public API ------------------------------------------------------------
+
+/**
+ * Resolve the publish target: the symlink referent when the given path is an
+ * existing symlink, the path itself otherwise. Only ENOENT (target absent yet)
+ * may fall back to the given path; any other resolution error (EACCES, EIO, ...)
+ * propagates so a broken or unreadable symlink is never written through its
+ * link path. Callers that stage a temp file themselves must stage it beside
+ * the resolved path: the commit is a rename onto the referent, and a rename
+ * across filesystems fails with EXDEV.
+ */
+export async function resolvePublishTarget(absoluteFilePath: string): Promise {
+ return fs.realpath(absoluteFilePath).catch(async (error: unknown) => {
+ if (errorCode(error) !== "ENOENT") throw error
+ // ENOENT also covers a dangling symlink, which must never be written through.
+ // Only a lstat that also reports the path as absent may fall back to the
+ // given path; a real lstat failure (EACCES, EIO) says nothing about whether
+ // the path is a link, so falling back would write through a link we were
+ // simply not allowed to inspect.
+ const linkStat = await fs.lstat(absoluteFilePath).catch((lstatError: unknown) => {
+ if (errorCode(lstatError) === "ENOENT") return undefined
+ throw lstatError
+ })
+ if (linkStat?.isSymbolicLink()) throw error
+ return absoluteFilePath
+ })
+}
+/**
+ * Distinguish "the target does not exist" from a real I/O failure (EACCES,
+ * EIO, ...). The mode-preservation path may only fall back to the fresh-file
+ * default on ENOENT; any other failure is propagated, otherwise a restrictive
+ * target (0o600) would be published with the default 0o644 through the rename.
+ */
+function errorCode(error: unknown): string | undefined {
+ return typeof error === "object" && error !== null && "code" in error
+ ? String((error as { code: unknown }).code)
+ : undefined
+}
+
+/**
+ * Canonicalize the parent directory and re-join the basename. fs.realpath
+ * canonicalizes every component, including a symlinked ancestor directory or a
+ * Windows 8.3 short name, so a lock key must be canonical even when the file
+ * itself is not there yet -- otherwise the key for one file depends on whether
+ * the file exists when the key is computed, and two writers take two locks.
+ */
+
+async function canonicalDirKey(absoluteFilePath: string): Promise {
+ const dirPath = path.dirname(absoluteFilePath)
+ const canonicalDir = await fs.realpath(dirPath).catch(() => dirPath)
+ return path.join(canonicalDir, path.basename(absoluteFilePath))
+}
+
+/**
+ * Lock key for a publish target: the symlink referent when the path is an
+ * existing symlink, the path itself otherwise. Unlike resolvePublishTarget this
+ * tolerates a dangling link, because the lock key has to be computable while a
+ * peer writer is mid-commit (a publish renames the staged file onto the referent,
+ * and backup mode keeps a copy beside it).
+ * The walk is bounded so a two-link cycle terminates, and every key it returns is
+ * canonicalized through canonicalDirKey.
+ */
+export async function resolveLockKey(absoluteFilePath: string): Promise {
+ try {
+ return await canonicalDirKey(await resolvePublishTarget(absoluteFilePath))
+ } catch {
+ // A real readlink throws for anything that is not a link, so a normal chain
+ // ends the walk. Two links that point at each other never would, so the
+ // walk is bounded and callers use the key they actually reached.
+ let key = absoluteFilePath
+ for (let depth = 0; depth < 8; depth++) {
+ const target = await fs.readlink(key).catch(() => undefined)
+ if (target === undefined) return await canonicalDirKey(key)
+ key = await canonicalDirKey(path.resolve(path.dirname(key), target))
+ }
+ return await canonicalDirKey(key)
+ }
+}
+
+/**
+ * Remove one residue path this write created, retrying once: Windows reports EPERM for a path whose
+ * handle has not been released yet, so a single failure is not evidence that the path is stuck.
+ * ENOENT counts as removed - the goal is that the path is gone, not that this call performed the
+ * removal. `remove` is the filesystem call for this residue kind (`fs.unlink` for a file, `fs.rmdir`
+ * for a directory), so every residue this operation owns is removed under one set of rules rather
+ * than one per kind. Returns the error that kept the path on disk, or null when it is gone.
+ */
+async function _removeResidue(residuePath: string, remove: (residuePath: string) => Promise): Promise {
+ let lastError: unknown = null
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ await remove(residuePath)
+ return null
+ } catch (error: unknown) {
+ if (errorCode(error) === "ENOENT") {
+ return null
+ }
+ lastError = error
+ }
+ }
+ return lastError
+}
+
+/**
+ * Attach the residue of a failed publish to the error being thrown, preserving that error: the
+ * caller's `instanceof` checks, its message and its cause all stay exactly as they were, and the
+ * residue is extra information on top. A publish that cleaned everything up attaches nothing, so a
+ * caller never has to tell an empty list apart from no residue at all. A thrown value that is not an
+ * object cannot carry the lists, and replacing it with one that could would change the error the
+ * caller catches, so it is returned as it came.
+ */
+function _attachResidue(error: unknown, residue: PublishResidue): unknown {
+ if (residue.leftoverPaths.length === 0 || typeof error !== "object" || error === null) {
+ return error
+ }
+ const carrier = error as PublishResidue
+ carrier.leftoverPaths = residue.leftoverPaths
+ carrier.cleanupErrors = residue.cleanupErrors
+ return error
+}
+
+export async function safeWriteText(
+ filePath: string,
+ content: string | Uint8Array,
+ options?: SafeWriteTextOptions,
+): Promise {
+ const absoluteFilePath = path.resolve(filePath)
+ // Every leftover is recorded here as it is discovered, so a caller gets a structured result
+ // instead of having to parse warnings to learn that content is still on disk. A rejection cannot
+ // carry that result, so the same two lists are attached to the error it throws instead.
+ const leftoverPaths: string[] = []
+ const cleanupErrors: unknown[] = []
+
+ /**
+ * Remove one residue path this write created and record it when it is still there. Every residue
+ * this operation owns - staged file, staging directory, DACL dump, backup copy - is removed
+ * through here, so a path that survives is reported the same way whichever step left it behind.
+ */
+ const cleanResidue = async (
+ residuePath: string,
+ remove: (residuePath: string) => Promise,
+ ): Promise => {
+ const cleanupError = await _removeResidue(residuePath, remove)
+ if (cleanupError !== null) {
+ leftoverPaths.push(residuePath)
+ cleanupErrors.push(cleanupError)
+ }
+ return cleanupError
+ }
+
+ // Warning delivery must never abort the write: the notices below describe a
+ // committed-but-imperfect publish, and a caller whose callback throws (a UI sink, a logger that
+ // is mid-restart) must not turn that into a failed save. Declared above the try whose failure
+ // handler reports a leftover backup copy, so both sides can reach it.
+ const warn = (message: string) => {
+ const report = (label: string, error: unknown) => {
+ console.warn(
+ `safeWriteText: onWarning callback ${label}: ${error instanceof Error ? error.message : String(error)}`,
+ )
+ }
+ try {
+ const sink = options?.onWarning ?? ((m: string) => console.warn(m))
+ const result: unknown = sink(message)
+ // A sink may be async - TypeScript accepts a value-returning callback where
+ // a void one is expected. Awaiting it would let warning delivery delay a
+ // write that has already committed (and hang it if the sink never settles),
+ // while leaving the promise unhandled turns a rejection into an unhandled
+ // rejection, which under Node's default mode can end the process after a
+ // successful write. Attach a handler without awaiting.
+ if (result instanceof Promise) {
+ result.catch((error: unknown) => report("rejected", error))
+ }
+ } catch (error: unknown) {
+ report("failed", error)
+ }
+ }
+
+ // Resolve the symlink referent (see resolvePublishTarget).
+ const targetPath = await resolvePublishTarget(absoluteFilePath)
+ const dirPath = path.dirname(targetPath)
+
+ // Ensure parent directory exists (mirrors safeWriteJson behaviour).
+ await fs.mkdir(dirPath, { recursive: true })
+ await fs.access(dirPath)
+
+ // Create the staging directory only when we generate the temp file there;
+ // callers supplying their own tempPath (e.g. safeWriteJson) must not be left
+ // with an empty .file-safety-staging directory behind. Track the directory this
+ // write created so its cleanup removes its own directory, not a shared one.
+ let stagingDir: string | null = null
+ let tempPath: string
+ if (options?.tempPath) {
+ // A caller-supplied staging file is only safe when it is the file this
+ // write is staging, not an arbitrary path. Two properties are checked:
+ // it must sit beside the resolved target (a rename across filesystems
+ // fails with EXDEV, and a path elsewhere lets a caller publish an
+ // unrelated file onto the target), and it must be a regular file rather
+ // than a link — renaming a link over the target publishes whatever the
+ // link points at, which is the same trust problem as writing through a
+ // dangling symlink in resolvePublishTarget.
+ const supplied = path.resolve(options.tempPath)
+ if (path.dirname(supplied) !== path.resolve(dirPath)) {
+ throw new StagingPathError(
+ `Staging file must sit in the target's directory (${dirPath}), got ${supplied}`,
+ supplied,
+ )
+ }
+ // BigInt stats: on NTFS/ReFS the file identity can exceed Number.MAX_SAFE_INTEGER, and
+ // a rounded number makes two different files look identical (rejecting a valid staging
+ // file) or hides a real alias.
+ const stagingStat = await fs.lstat(supplied, { bigint: true })
+ if (stagingStat.isSymbolicLink() || !stagingStat.isFile()) {
+ throw new StagingPathError(
+ `Staging file must be a regular file, not ${stagingStat.isSymbolicLink() ? "a symlink" : "another file type"}`,
+ supplied,
+ )
+ }
+ // A staging path that is the target would be unlinked by the failure handler
+ // while it still holds the only copy of the content, so a failed write would
+ // delete the file it was meant to protect. Compare identities, not spellings:
+ // an alias of the target is the same hazard.
+ // Only a missing target may be skipped: an EACCES/ELOOP/ENOTDIR here means the
+ // identity comparison could not be made, and treating that as "no target" would let a
+ // staging alias reach the commit and let cleanup delete the file it was meant to
+ // protect.
+ const targetStat = await fs.lstat(targetPath, { bigint: true }).catch((error: unknown) => {
+ if (errorCode(error) !== "ENOENT") {
+ throw new StagingPathError("Staging file could not be compared with the target", supplied)
+ }
+ return null
+ })
+ if (
+ targetStat &&
+ typeof stagingStat.ino === "bigint" &&
+ typeof targetStat.ino === "bigint" &&
+ stagingStat.ino === targetStat.ino &&
+ stagingStat.dev === targetStat.dev
+ ) {
+ throw new StagingPathError("Staging file must not be the target itself", supplied)
+ }
+ // The caller's own path is used as given; only the check is canonical.
+ tempPath = options.tempPath
+ } else {
+ stagingDir = _stagingDir(dirPath)
+ tempPath = _tempName(stagingDir, "safeWriteText")
+ }
+
+ let backupPath: string | null = null
+ let releaseBackupOnSuccess = false
+ // Non-null only when the win32 step-2 block saved a successful DACL dump:
+ // it gates the step-5 restore and is tracked for the cleanup unlinks.
+ let daclDumpPath: string | null = null
+ try {
+ // -- Step 1: write content to staging temp file -------------------
+ if (!options?.tempPath) {
+ // Preserve the existing target's permissions: the staging file must
+ // not be published wider than the file it replaces (a 0o600 target
+ // must not become 0o644 through the atomic rename).
+ // Encode before opening the staging file: an encoding Node cannot
+ // represent must not leave a half-written temp file behind.
+ // A string is encoded as UTF-8; bytes handed in by the caller (the
+ // extension host encodes a document with VS Code's own codec, which
+ // covers the legacy code pages Node cannot represent) are published
+ // unchanged.
+ const buffer = Buffer.from(content)
+ let targetMode = 0o644 // default for a fresh target
+ let targetExists = false
+ try {
+ targetMode = fsSync.statSync(targetPath).mode & 0o777
+ targetExists = true
+ } catch (error: unknown) {
+ if (errorCode(error) !== "ENOENT") throw error
+ // target does not exist yet - keep the default
+ }
+ // openSync's creation mode is narrowed by the process umask, so an
+ // existing 0o664 target would be published as 0o644 through the
+ // rename. Apply the existing target's exact mode on the fd, as the
+ // caller-staged branch does; a fresh target keeps the default mode.
+ const fd = fsSync.openSync(tempPath, "w", targetMode)
+ try {
+ if (targetExists) {
+ fsSync.fchmodSync(fd, targetMode)
+ }
+ // Loop until every byte is written: writeSync can report a short
+ // (partial) write, and publishing a truncated staging file would
+ // commit corrupt content.
+ let offset = 0
+ while (offset < buffer.length) {
+ offset += fsSync.writeSync(fd, buffer, offset, buffer.length - offset)
+ }
+ _fsyncFile(fd)
+ } finally {
+ fsSync.closeSync(fd)
+ }
+ } else {
+ // Preserve the existing target's mode (CWE-732): the caller-staged
+ // temp carries its own creation mode, and publishing it as-is would
+ // widen a restrictive target (e.g. 0o600 -> 0o644) through rename.
+ // The mode is applied with fchmodSync on the open fd (AFTER openSync):
+ // chmodSync on the path before the open would make a read-only target
+ // (0o400/0o444) fail openSync(tempPath, "r+") with EACCES.
+ let targetMode: number | null = null
+ try {
+ targetMode = fsSync.statSync(targetPath).mode & 0o777
+ } catch (error: unknown) {
+ if (errorCode(error) !== "ENOENT") throw error
+ // target does not exist yet - keep the temp's default mode
+ }
+ const fd = fsSync.openSync(tempPath, "r+")
+ try {
+ if (targetMode !== null) {
+ fsSync.fchmodSync(fd, targetMode)
+ }
+ _fsyncFile(fd)
+ } finally {
+ fsSync.closeSync(fd)
+ }
+ }
+
+ // -- Step 2 (win32): save DACL BEFORE the backup copy -----------
+ const platform = options?.platform ?? process.platform
+ if (platform === "win32") {
+ let accessError: unknown = null
+ try {
+ await fs.access(targetPath) // target exists?
+ } catch (error: unknown) {
+ accessError = error
+ }
+ if (accessError === null) {
+ const dumpPath = _tempName(dirPath, "safeWriteText.acl")
+ const saved = await _saveDaclWindows(targetPath, dumpPath, options?.execFileRunner)
+ if (saved) {
+ // Only a successfully saved dump may be restored onto the
+ // committed file (step 5).
+ daclDumpPath = dumpPath
+ } else {
+ // A failed icacls may have left a partial dump behind; remove it now so no partial
+ // dump survives and no later step can restore from it. A dump that cannot be removed is
+ // recorded, because the refusal below throws: without the record the caller would hold
+ // an error naming no file while a partial DACL dump stayed on disk.
+ await cleanResidue(dumpPath, fs.unlink)
+ // The target exists and its DACL could not be captured, so the commit rename
+ // would replace it with a file that inherits different access rights. Nothing here
+ // can verify an equivalent restrictive ACL on the replacement, so the publish is
+ // refused instead of warned about: a save that silently changed who can read the
+ // file is not a save the user can trust. Nothing is committed yet, so the target
+ // still holds its content, and this throw lands in the catch at the bottom of this
+ // try - the one place that unlinks the staged file and removes this write's own
+ // staging directory before rethrowing - so a refused publish strands no residue.
+ throw new DaclInspectionError(targetPath, "save", null)
+ }
+ } else if (errorCode(accessError) !== "ENOENT") {
+ // Not "absent": the target is there but its access rights could not be read (EACCES,
+ // ...), so publishing would replace a file whose rights this call never learned. Same
+ // rule as the save failure above: refuse before anything is committed, and let the
+ // catch at the bottom of this try remove the staged file and the staging directory.
+ throw new DaclInspectionError(targetPath, "inspect", accessError)
+ }
+ }
+ try {
+ // -- Step 3 (backup:true): durable copy target -> backup ----
+ if (options?.backup) {
+ try {
+ await fs.access(targetPath)
+ backupPath = _tempName(dirPath, "safeWriteText.bak")
+ // Copy, never move. Renaming the target away leaves the canonical path absent for
+ // the whole commit window: readers see a missing file, and a concurrent
+ // writer can create a new target that a later rollback would destroy. A copy
+ // keeps the target present, so the step 4 rename is the only change to the
+ // canonical path. The copy is flushed so the retained content survives a crash.
+ try {
+ // Create the destination BEFORE any content exists at it, with the mode fixed
+ // at open time. fs.copyFile picks the destination mode itself (the platform
+ // creation mask subject to umask on some platforms, the source's mode - or its
+ // read-only attribute - on others), so letting it create the file would either
+ // leave a restrictive target's bytes briefly readable to others, or leave the
+ // copy unwritable so the fsync open below fails with EACCES. open() ignores its
+ // mode argument for an existing file, so this 0o600 survives the copy on POSIX;
+ // the chmod afterwards is what clears a copied read-only attribute on Windows
+ // and keeps a backup of a permissive file private.
+ const seedFd = fsSync.openSync(backupPath, "wx", 0o600)
+ // Single close, no retry: on POSIX close(2) can release the descriptor before it
+ // reports an error (and leaves its state unspecified after EINTR), so a second
+ // close could release a descriptor some other operation has meanwhile reused.
+ // The failure propagates; backupPath is already recorded, so the outer cleanup
+ // removes the seeded file instead of leaving it beside the target.
+ fsSync.closeSync(seedFd)
+ await fs.copyFile(targetPath, backupPath)
+ await fs.chmod(backupPath, 0o600)
+ // "r+" not "r": fsync on a read-only handle is EPERM on Windows, and the same
+ // flag the staged temp file uses above.
+ const backupFd = fsSync.openSync(backupPath, "r+")
+ try {
+ _fsyncFile(backupFd)
+ } finally {
+ fsSync.closeSync(backupFd)
+ }
+ } catch (backupError: unknown) {
+ // A partial backup must not outlive this attempt: it is not a complete copy
+ // of anything, and once the write fails nothing else removes it. The unlink is
+ // retried once (Windows reports EPERM for a file whose handle has not been
+ // released yet); if it still fails the path is carried on the thrown error
+ // instead of being dropped where no caller can act on it.
+ const orphanPath = backupPath
+ // Same rule as every other residue: one retry, ENOENT counts as removed, and a copy
+ // that stays is recorded so the error thrown below carries it too.
+ const backupCleanupError = await cleanResidue(orphanPath, fs.unlink)
+ backupPath = null
+ if (backupCleanupError !== null) {
+ throw new OrphanedBackupError(orphanPath, targetPath, backupError, backupCleanupError)
+ }
+ throw backupError
+ }
+ releaseBackupOnSuccess = true
+ } catch (err: unknown) {
+ if (errorCode(err) !== "ENOENT") throw err
+ }
+ }
+
+ // -- 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 (error: unknown) {
+ // The content rename committed, but the directory entry that
+ // points at it is not known to be durable. Reporting success
+ // here would let a caller believe the write survives a crash,
+ // so the failure is surfaced as its own error: the caller can
+ // still find the content at the target, it just cannot rely on
+ // the directory entry having reached the disk.
+ throw new PostCommitDurabilityError(targetPath, error)
+ }
+ }
+
+ // -- Step 5 (win32): restore DACL AFTER commit rename ---------
+ // daclDumpPath is non-null only when the win32 step-2 block saved a
+ // successful dump, so this gate is closed on every other platform
+ // and on every failed save.
+ if (daclDumpPath !== null) {
+ const restoredDir = path.dirname(targetPath)
+ const restored = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner)
+ if (!restored) {
+ // The content is committed, but the published file answers to a different
+ // DACL from the one that was saved, and nothing here verified an equivalent
+ // restrictive ACL on the replacement. This is reported as an error rather than a
+ // warning: the caller has to know that the save changed who can read the file.
+ // Contract change - this used to warn and resolve, which let a publish that
+ // widened access look like an ordinary successful save.
+ throw new DaclRestoreError(targetPath, null)
+ }
+ }
+
+ // -- Step 6 (backup:true): delete backup on success -----------
+ if (releaseBackupOnSuccess && backupPath) {
+ // The backup is a full copy of the previous content sitting next to the published file.
+ // A failed unlink goes through the shared residue path, which retries once (Windows
+ // commonly reports EPERM while another handle is still being released) and records a path
+ // that stays. The publish itself succeeded, so this is a leftover to clean up rather than
+ // a failed save: the path reaches the human through onWarning and the caller through the
+ // structured result, because a full copy of the previous content is still beside the target.
+ const cleanupError = await cleanResidue(backupPath, fs.unlink)
+ if (cleanupError !== null) {
+ warn(
+ `safeWriteText: committed ${targetPath} but could not remove its backup copy at ${backupPath} (${
+ errorCode(cleanupError) ?? "unknown error"
+ }); the copy of the previous content is still on disk and needs to be removed.`,
+ )
+ }
+ }
+ } finally {
+ // Unlink the DACL dump on every exit from this span, success or failure, and record one that
+ // stays. This is the dump's only owner once a save succeeded, so what is recorded here is
+ // what the caller receives on either exit: a resolved write returns the list, and a rejected
+ // one carries the same list on its error instead of dropping it with the discarded result.
+ if (daclDumpPath !== null) {
+ await cleanResidue(daclDumpPath, fs.unlink)
+ }
+ }
+
+ // tempPath is now the committed file; no cleanup needed.
+
+ // Remove the now-empty staging directory through the same cleanup path as every other residue.
+ // Self-staged writes only, and only this write's own directory: a per-write directory cannot be
+ // the one another concurrent write is still using. A failure must not un-commit a published
+ // file, but a directory this write created and could not remove is residue the caller is told
+ // about rather than a failure that disappears.
+ if (stagingDir !== null) {
+ await cleanResidue(stagingDir, fs.rmdir)
+ }
+
+ // The result is reported only after this call's own residue has been dealt with: a caller
+ // that receives an empty list knows there is nothing left for it to sweep.
+ return { leftoverPaths }
+ } catch (originalError: unknown) {
+ // The backup is a copy, never a restore source: whether the failure happened before
+ // or after the commit rename, the copy is removed below so no stale duplicate of the
+ // previous content survives next to the target.
+ if (backupPath && releaseBackupOnSuccess) {
+ // Nothing to restore: the backup is a copy, so the target still holds whatever
+ // the commit left there - before the commit that is the pre-write content, and
+ // after it the published content. Either way the copy has served its purpose
+ // and must not be left beside the target where no caller can find it.
+ const stuckBackup = await cleanResidue(backupPath, fs.unlink)
+ if (stuckBackup !== null) {
+ // The original error is what the caller needs, so the leftover cannot be thrown, and a
+ // throw means the structured result never reaches the caller either: the path is
+ // reported with its location through onWarning, and attached to the error below, so
+ // neither the human nor the caller has to parse a message to find it.
+ warn(
+ `safeWriteText: the write failed and its backup copy could not be removed from ${backupPath} (${
+ errorCode(stuckBackup) ?? "unknown error"
+ }); a full copy of the previous content is still on disk and needs to be removed.`,
+ )
+ }
+ backupPath = null
+ }
+
+ // The staged file and this write's own staging directory go through the same cleanup path as
+ // every other residue, so a failure at either is recorded and reported on the error below
+ // instead of being swallowed here: a caller that catches a throw has no result to read.
+ await cleanResidue(tempPath, fs.unlink)
+
+ // A failed self-staged write must not leave its staging directory behind.
+ // Only the directory this write created, and only after its temp file is
+ // gone, so the directory is empty and the removal stays best-effort.
+ if (stagingDir !== null) {
+ await cleanResidue(stagingDir, fs.rmdir)
+ }
+
+ // The DACL dump is not removed here. It is non-null only once step 2 saved a dump, and every
+ // path from that point enters the try whose finally owns the dump, so by the time this handler
+ // runs the dump has already been removed or recorded. Removing it again here would compensate
+ // for a resource this span no longer owns and would report a surviving dump twice.
+ throw _attachResidue(originalError, { leftoverPaths, cleanupErrors })
+ }
+}