Skip to content

Commit de94126

Browse files
committed
Infra: relax win32 guarded-open identity checks
1 parent 96fc5dd commit de94126

File tree

2 files changed

+144
-3
lines changed

2 files changed

+144
-3
lines changed

src/infra/safe-open-sync.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import fs from "node:fs";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { openVerifiedFileSync } from "./safe-open-sync.js";
4+
5+
type TestIoFs = NonNullable<Parameters<typeof openVerifiedFileSync>[0]["ioFs"]>;
6+
7+
function createStats(params: {
8+
dev: number | bigint;
9+
ino: number | bigint;
10+
nlink?: number;
11+
size?: number;
12+
file?: boolean;
13+
symlink?: boolean;
14+
}): fs.Stats {
15+
return {
16+
dev: params.dev,
17+
ino: params.ino,
18+
nlink: params.nlink ?? 1,
19+
size: params.size ?? 16,
20+
isFile: () => params.file ?? true,
21+
isSymbolicLink: () => params.symlink ?? false,
22+
} as unknown as fs.Stats;
23+
}
24+
25+
function createIoFs(params: { realPath?: string; lstat: fs.Stats; fstat: fs.Stats }): {
26+
ioFs: TestIoFs;
27+
mocks: {
28+
lstatSync: ReturnType<typeof vi.fn>;
29+
realpathSync: ReturnType<typeof vi.fn>;
30+
openSync: ReturnType<typeof vi.fn>;
31+
fstatSync: ReturnType<typeof vi.fn>;
32+
closeSync: ReturnType<typeof vi.fn>;
33+
};
34+
} {
35+
const realPath = params.realPath ?? "C:/tmp/demo.json";
36+
const lstatSync = vi.fn(() => params.lstat);
37+
const realpathSync = vi.fn(() => realPath);
38+
const openSync = vi.fn(() => 42);
39+
const fstatSync = vi.fn(() => params.fstat);
40+
const closeSync = vi.fn(() => undefined);
41+
42+
const ioFs = {
43+
constants: { O_RDONLY: 0, O_NOFOLLOW: 0 },
44+
lstatSync,
45+
realpathSync,
46+
openSync,
47+
fstatSync,
48+
closeSync,
49+
} as unknown as TestIoFs;
50+
51+
return {
52+
ioFs,
53+
mocks: {
54+
lstatSync,
55+
realpathSync,
56+
openSync,
57+
fstatSync,
58+
closeSync,
59+
},
60+
};
61+
}
62+
63+
describe("openVerifiedFileSync", () => {
64+
it("rejects identity mismatch on non-windows", () => {
65+
const preOpen = createStats({ dev: 1, ino: 100 });
66+
const opened = createStats({ dev: 2, ino: 200 });
67+
const { ioFs, mocks } = createIoFs({ lstat: preOpen, fstat: opened });
68+
69+
const result = openVerifiedFileSync({
70+
filePath: "C:/tmp/demo.json",
71+
ioFs,
72+
platform: "linux",
73+
});
74+
75+
expect(result).toEqual({ ok: false, reason: "validation" });
76+
expect(mocks.openSync).toHaveBeenCalledTimes(1);
77+
expect(mocks.closeSync).toHaveBeenCalledWith(42);
78+
});
79+
80+
it("allows win32 identity mismatch for regular non-hardlinked files", () => {
81+
const preOpen = createStats({ dev: 1, ino: 100, nlink: 1 });
82+
const opened = createStats({ dev: 2, ino: 200, nlink: 1 });
83+
const { ioFs, mocks } = createIoFs({ lstat: preOpen, fstat: opened });
84+
85+
const result = openVerifiedFileSync({
86+
filePath: "C:/tmp/demo.json",
87+
ioFs,
88+
platform: "win32",
89+
rejectHardlinks: true,
90+
});
91+
92+
expect(result.ok).toBe(true);
93+
if (!result.ok) {
94+
throw new Error("expected open result");
95+
}
96+
expect(result.path).toBe("C:/tmp/demo.json");
97+
expect(result.fd).toBe(42);
98+
expect(mocks.closeSync).not.toHaveBeenCalled();
99+
});
100+
101+
it("still rejects win32 files when hardlink guard fails", () => {
102+
const preOpen = createStats({ dev: 1, ino: 100, nlink: 1 });
103+
const opened = createStats({ dev: 2, ino: 200, nlink: 2 });
104+
const { ioFs, mocks } = createIoFs({ lstat: preOpen, fstat: opened });
105+
106+
const result = openVerifiedFileSync({
107+
filePath: "C:/tmp/demo.json",
108+
ioFs,
109+
platform: "win32",
110+
rejectHardlinks: true,
111+
});
112+
113+
expect(result).toEqual({ ok: false, reason: "validation" });
114+
expect(mocks.closeSync).toHaveBeenCalledWith(42);
115+
});
116+
});

src/infra/safe-open-sync.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,23 @@ function isExpectedPathError(error: unknown): boolean {
1818
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
1919
}
2020

21-
export function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean {
22-
return hasSameFileIdentity(left, right);
21+
function shouldBypassWin32IdentityMismatch(params: {
22+
platform: NodeJS.Platform;
23+
preOpenStat: fs.Stats;
24+
openedStat: fs.Stats;
25+
}): boolean {
26+
if (params.platform !== "win32") {
27+
return false;
28+
}
29+
return params.preOpenStat.nlink <= 1 && params.openedStat.nlink <= 1;
30+
}
31+
32+
export function sameFileIdentity(
33+
left: fs.Stats,
34+
right: fs.Stats,
35+
platform: NodeJS.Platform = process.platform,
36+
): boolean {
37+
return hasSameFileIdentity(left, right, platform);
2338
}
2439

2540
export function openVerifiedFileSync(params: {
@@ -29,8 +44,10 @@ export function openVerifiedFileSync(params: {
2944
rejectHardlinks?: boolean;
3045
maxBytes?: number;
3146
ioFs?: SafeOpenSyncFs;
47+
platform?: NodeJS.Platform;
3248
}): SafeOpenSyncResult {
3349
const ioFs = params.ioFs ?? fs;
50+
const platform = params.platform ?? process.platform;
3451
const openReadFlags =
3552
ioFs.constants.O_RDONLY |
3653
(typeof ioFs.constants.O_NOFOLLOW === "number" ? ioFs.constants.O_NOFOLLOW : 0);
@@ -66,7 +83,15 @@ export function openVerifiedFileSync(params: {
6683
if (params.maxBytes !== undefined && openedStat.size > params.maxBytes) {
6784
return { ok: false, reason: "validation" };
6885
}
69-
if (!sameFileIdentity(preOpenStat, openedStat)) {
86+
87+
if (
88+
!sameFileIdentity(preOpenStat, openedStat, platform) &&
89+
!shouldBypassWin32IdentityMismatch({
90+
platform,
91+
preOpenStat,
92+
openedStat,
93+
})
94+
) {
7095
return { ok: false, reason: "validation" };
7196
}
7297

0 commit comments

Comments
 (0)