diff --git a/src/infra/safe-open-sync.test.ts b/src/infra/safe-open-sync.test.ts new file mode 100644 index 00000000000..62199e8b1e2 --- /dev/null +++ b/src/infra/safe-open-sync.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { openVerifiedFileSync } from "./safe-open-sync.js"; + +type TestIoFs = NonNullable[0]["ioFs"]>; + +function createStats(params: { + dev: number | bigint; + ino: number | bigint; + nlink?: number; + size?: number; + file?: boolean; + symlink?: boolean; +}): fs.Stats { + return { + dev: params.dev, + ino: params.ino, + nlink: params.nlink ?? 1, + size: params.size ?? 16, + isFile: () => params.file ?? true, + isSymbolicLink: () => params.symlink ?? false, + } as unknown as fs.Stats; +} + +function createIoFs(params: { realPath?: string; lstat: fs.Stats; fstat: fs.Stats }): { + ioFs: TestIoFs; + mocks: { + lstatSync: ReturnType; + realpathSync: ReturnType; + openSync: ReturnType; + fstatSync: ReturnType; + closeSync: ReturnType; + }; +} { + const realPath = params.realPath ?? "C:/tmp/demo.json"; + const lstatSync = vi.fn(() => params.lstat); + const realpathSync = vi.fn(() => realPath); + const openSync = vi.fn(() => 42); + const fstatSync = vi.fn(() => params.fstat); + const closeSync = vi.fn(() => undefined); + + const ioFs = { + constants: { O_RDONLY: 0, O_NOFOLLOW: 0 }, + lstatSync, + realpathSync, + openSync, + fstatSync, + closeSync, + } as unknown as TestIoFs; + + return { + ioFs, + mocks: { + lstatSync, + realpathSync, + openSync, + fstatSync, + closeSync, + }, + }; +} + +describe("openVerifiedFileSync", () => { + it("rejects identity mismatch on non-windows", () => { + const preOpen = createStats({ dev: 1, ino: 100 }); + const opened = createStats({ dev: 2, ino: 200 }); + const { ioFs, mocks } = createIoFs({ lstat: preOpen, fstat: opened }); + + const result = openVerifiedFileSync({ + filePath: "C:/tmp/demo.json", + ioFs, + platform: "linux", + }); + + expect(result).toEqual({ ok: false, reason: "validation" }); + expect(mocks.openSync).toHaveBeenCalledTimes(1); + expect(mocks.closeSync).toHaveBeenCalledWith(42); + }); + + it("allows win32 identity mismatch for regular non-hardlinked files", () => { + const preOpen = createStats({ dev: 1, ino: 100, nlink: 1 }); + const opened = createStats({ dev: 2, ino: 200, nlink: 1 }); + const { ioFs, mocks } = createIoFs({ lstat: preOpen, fstat: opened }); + + const result = openVerifiedFileSync({ + filePath: "C:/tmp/demo.json", + ioFs, + platform: "win32", + rejectHardlinks: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("expected open result"); + } + expect(result.path).toBe("C:/tmp/demo.json"); + expect(result.fd).toBe(42); + expect(mocks.closeSync).not.toHaveBeenCalled(); + }); + + it("still rejects win32 files when hardlink guard fails", () => { + const preOpen = createStats({ dev: 1, ino: 100, nlink: 1 }); + const opened = createStats({ dev: 2, ino: 200, nlink: 2 }); + const { ioFs, mocks } = createIoFs({ lstat: preOpen, fstat: opened }); + + const result = openVerifiedFileSync({ + filePath: "C:/tmp/demo.json", + ioFs, + platform: "win32", + rejectHardlinks: true, + }); + + expect(result).toEqual({ ok: false, reason: "validation" }); + expect(mocks.closeSync).toHaveBeenCalledWith(42); + }); +}); diff --git a/src/infra/safe-open-sync.ts b/src/infra/safe-open-sync.ts index b502b04e90b..e290c82f8cb 100644 --- a/src/infra/safe-open-sync.ts +++ b/src/infra/safe-open-sync.ts @@ -18,8 +18,23 @@ function isExpectedPathError(error: unknown): boolean { return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP"; } -export function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean { - return hasSameFileIdentity(left, right); +function shouldBypassWin32IdentityMismatch(params: { + platform: NodeJS.Platform; + preOpenStat: fs.Stats; + openedStat: fs.Stats; +}): boolean { + if (params.platform !== "win32") { + return false; + } + return params.preOpenStat.nlink <= 1 && params.openedStat.nlink <= 1; +} + +export function sameFileIdentity( + left: fs.Stats, + right: fs.Stats, + platform: NodeJS.Platform = process.platform, +): boolean { + return hasSameFileIdentity(left, right, platform); } export function openVerifiedFileSync(params: { @@ -29,8 +44,10 @@ export function openVerifiedFileSync(params: { rejectHardlinks?: boolean; maxBytes?: number; ioFs?: SafeOpenSyncFs; + platform?: NodeJS.Platform; }): SafeOpenSyncResult { const ioFs = params.ioFs ?? fs; + const platform = params.platform ?? process.platform; const openReadFlags = ioFs.constants.O_RDONLY | (typeof ioFs.constants.O_NOFOLLOW === "number" ? ioFs.constants.O_NOFOLLOW : 0); @@ -66,7 +83,15 @@ export function openVerifiedFileSync(params: { if (params.maxBytes !== undefined && openedStat.size > params.maxBytes) { return { ok: false, reason: "validation" }; } - if (!sameFileIdentity(preOpenStat, openedStat)) { + + if ( + !sameFileIdentity(preOpenStat, openedStat, platform) && + !shouldBypassWin32IdentityMismatch({ + platform, + preOpenStat, + openedStat, + }) + ) { return { ok: false, reason: "validation" }; }