Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/root-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions test/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<JsonFileReadError>);
expect(racesInjected).toBeGreaterThanOrEqual(5);
expect(racesInjected).toBe(5);
} finally {
openSpy.mockRestore();
}
Expand Down
38 changes: 36 additions & 2 deletions test/path-stress-regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ import { __setFsSafeTestHooksForTest } from "../src/test-hooks.js";

const { tempRoot } = useTempDirs();

async function renameForRace(from: string, to: string): Promise<void> {
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<void> {
await new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -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);
},
});

Expand All @@ -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");
Expand Down