diff --git a/src/root-impl.ts b/src/root-impl.ts index 0f6d9d8..b8c76e7 100644 --- a/src/root-impl.ts +++ b/src/root-impl.ts @@ -219,13 +219,15 @@ async function openVerifiedLocalFile( if (!preOpenStat.isFile() && !preOpenStat.isSymbolicLink()) { throw new FsSafeError("not-file", "not a file"); } - await fsSafeTestHooks?.afterPreOpenLstat?.(filePath); } catch (err) { if (err instanceof FsSafeError) { throw err; } // ENOENT and other lstat errors: fall through and let fs.open handle. } + if (preOpenStat) { + await fsSafeTestHooks?.afterPreOpenLstat?.(filePath); + } let handle: FileHandle; try { diff --git a/test/json.test.ts b/test/json.test.ts index 09abd48..e3c9bc7 100644 --- a/test/json.test.ts +++ b/test/json.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { itPosix, useTempDirs } from "./helpers/vitest.js"; import { createAsyncLock } from "../src/async-lock.js"; import { writeTextAtomic } from "../src/atomic.js"; +import { FsSafeError } from "../src/errors.js"; import { JsonFileReadError, readRootJsonObjectSync, @@ -314,17 +315,18 @@ describe("json file helpers", () => { const openSpy = vi.spyOn(fs, "open").mockImplementation(async (...args) => { if (args[0] === filePath) { racesInjected += 1; - await writeTextAtomic(filePath, `{"v":${racesInjected}}`); + throw new FsSafeError("path-mismatch", "injected read race"); } - return originalOpen(...args); + return await originalOpen(...args); }); try { await expect(readJson(filePath)).rejects.toMatchObject({ name: "JsonFileReadError", reason: "read", + cause: expect.objectContaining({ code: "path-mismatch" }), } satisfies Partial); - expect(racesInjected).toBeGreaterThanOrEqual(5); + expect(racesInjected).toBe(5); } finally { openSpy.mockRestore(); } diff --git a/test/path-stress-regression.test.ts b/test/path-stress-regression.test.ts index d35b433..f0c7ae0 100644 --- a/test/path-stress-regression.test.ts +++ b/test/path-stress-regression.test.ts @@ -11,6 +11,23 @@ import { __setFsSafeTestHooksForTest } from "../src/test-hooks.js"; const { tempRoot } = useTempDirs(); +async function renameForRace(from: string, to: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await fs.rename(from, to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const transientWindowsDenial = + process.platform === "win32" && + (code === "EACCES" || code === "EBUSY" || code === "EPERM"); + if (!transientWindowsDenial || attempt === 9) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1))); + } + } +} async function createFifo(filePath: string): Promise { await new Promise((resolve, reject) => { @@ -112,8 +129,8 @@ describe("path stress regressions", () => { return; } swapped = true; - await fs.rename(targetDir, displacedDir); - await fs.rename(replacementDir, targetDir); + await renameForRace(targetDir, displacedDir); + await renameForRace(replacementDir, targetDir); }, }); @@ -123,6 +140,23 @@ describe("path stress regressions", () => { ); }); + it("propagates a race-hook failure after the pre-open inspection", async () => { + const rootDir = await tempRoot("fs-safe-parent-swap-hook-"); + await fs.writeFile(path.join(rootDir, "value.txt"), "trusted"); + const scoped = await openRoot(rootDir); + const targetFile = path.join(scoped.rootReal, "value.txt"); + const failure = new Error("race setup failed"); + __setFsSafeTestHooksForTest({ + afterPreOpenLstat(filePath) { + if (filePath === targetFile) { + throw failure; + } + }, + }); + + await expect(scoped.readText("value.txt")).rejects.toBe(failure); + }); + itPosix("rejects advisory access after the root pathname is replaced", async () => { const base = await tempRoot("fs-safe-root-identity-swap-"); const rootDir = path.join(base, "root");