Skip to content

Commit 976dcad

Browse files
committed
fix(permission): do not reuse lstat across dot segments
A successful lstat of sub/dang was stored under path.resolve, so sub/dang/. read back as that symlink. dangling follow then called readlink on the dotted path and returned unresolvable instead of sub/missing-target/missing. Relative keys that contain . or .. no longer share the collapsed path. A failed lstat of the dotted form still does not mark the symlink absent. Resolved paths are not reused across calls.
1 parent c378ee5 commit 976dcad

2 files changed

Lines changed: 31 additions & 15 deletions

File tree

‎src/permission/realpath-cache.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,24 @@ test("a failed dot lstat is not reused for the symlink it collapses onto", async
9393
}
9494
});
9595

96+
test("a dot segment does not reuse the dangling symlink lstat", async () => {
97+
scratch = await mkdtemp(join(tmpdir(), "corbits-realpath-dang-dot-"));
98+
const sub = join(scratch, "sub");
99+
await mkdir(sub);
100+
await symlink("missing-target", join(sub, "dang"));
101+
const previous = process.cwd();
102+
process.chdir(scratch);
103+
try {
104+
const target = join(process.cwd(), "sub", "missing-target");
105+
expect(realpathFollowingDangling("sub/dang/.")).toBe(target);
106+
expect(realpathFollowingDangling("sub/dang/./missing")).toBe(
107+
join(target, "missing"),
108+
);
109+
} finally {
110+
process.chdir(previous);
111+
}
112+
});
113+
96114
test("a later check of the same path lstats and realpaths again", async () => {
97115
scratch = await mkdtemp(join(tmpdir(), "corbits-realpath-once-"));
98116
const ancestorA = join(scratch, "a");

‎src/permission/realpath-cache.ts‎

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,24 @@ export function enterPathResolution<T>(fn: () => T): T {
2525
}
2626
}
2727

28-
function cacheKey(path: string): string {
29-
return isAbsolute(path) ? path : resolve(path);
30-
}
31-
32-
// `path.resolve` drops `.` and `..`, so `sub/link/.` and `sub/link` share a key.
33-
// A failed lstat of the dotted path is not "absent" for the symlink it collapses
34-
// onto (`..` through a symlink is a different node than the lexical parent).
35-
function failureCollapsesOntoAnotherPath(path: string): boolean {
36-
if (isAbsolute(path)) return false;
28+
function hasDotSegment(path: string): boolean {
3729
for (const segment of path.split(sep)) {
3830
if (segment === "." || segment === "..") return true;
3931
}
4032
return false;
4133
}
4234

35+
function cacheKey(path: string): string {
36+
// path.resolve drops `.` and `..`, so a successful lstat of `sub/dang` would
37+
// be reused for `sub/dang/.`. That path is not the symlink: readlink of the
38+
// dotted form throws, and a failed lstat of it must not be stored as absence
39+
// of `sub/dang` either. Absolute paths are already uncollapsed.
40+
if (!isAbsolute(path) && hasDotSegment(path)) {
41+
return `${resolve(".")}${sep}${path}`;
42+
}
43+
return isAbsolute(path) ? path : resolve(path);
44+
}
45+
4346
function errorCode(error: unknown): string | undefined {
4447
if (typeof error !== "object" || error === null || !("code" in error)) {
4548
return undefined;
@@ -58,12 +61,7 @@ function readLstat(path: string): LstatMemoEntry {
5861
} catch (error) {
5962
entry = { stats: undefined, code: errorCode(error) };
6063
}
61-
if (
62-
memo !== undefined &&
63-
!(entry.stats === undefined && failureCollapsesOntoAnotherPath(path))
64-
) {
65-
memo.set(key, entry);
66-
}
64+
memo?.set(key, entry);
6765
return entry;
6866
}
6967

0 commit comments

Comments
 (0)