|
1 | | -import { realpathSync } from "node:fs"; |
| 1 | +import { lstatSync, realpathSync } from "node:fs"; |
2 | 2 | import { dirname, join, resolve, sep } from "node:path"; |
3 | 3 | import { homedir } from "node:os"; |
4 | 4 | import type { RootsProvider } from "./worktree-roots.js"; |
@@ -39,22 +39,46 @@ function realpathOr(path: string): string { |
39 | 39 | } |
40 | 40 | } |
41 | 41 |
|
| 42 | +// Sentinel returned by realpathNearestOr for a path that exists but couldn't |
| 43 | +// be resolved (a dangling symlink, or a symlink loop) rather than one that's |
| 44 | +// simply missing. Contains a NUL byte, which can never appear in a real |
| 45 | +// filesystem path, so it can't collide with (or be mistaken for a prefix of) |
| 46 | +// any genuine result, and every containment compare against it fails. |
| 47 | +export const UNRESOLVABLE = "\0unresolvable\0"; |
| 48 | + |
42 | 49 | // A write/edit target usually doesn't exist yet, so realpath the nearest |
43 | 50 | // existing ancestor and rejoin the missing tail rather than falling back to |
44 | 51 | // the raw (possibly symlink-relative) path, which would defeat containment |
45 | 52 | // checks whenever the workspace root itself is reached through a symlink |
46 | 53 | // (e.g. macOS's /tmp -> /private/tmp). |
| 54 | +// |
| 55 | +// realpath failure is ambiguous: it's either "this component doesn't exist |
| 56 | +// yet" (safe — the missing tail gets rejoined onto the nearest real ancestor) |
| 57 | +// or "this component exists but is a dangling symlink / symlink loop" (unsafe |
| 58 | +// — the link's name must not stand in for a normal under-cwd path segment, |
| 59 | +// since the walk otherwise reattaches it verbatim and containment sees a |
| 60 | +// plain child path with no idea a symlink is involved). lstat distinguishes |
| 61 | +// the two: it succeeds for an existing-but-broken symlink and fails only when |
| 62 | +// the component is genuinely absent. |
47 | 63 | export function realpathNearestOr(path: string): string { |
48 | 64 | try { |
49 | 65 | return realpathSync(path); |
50 | 66 | } catch { |
| 67 | + try { |
| 68 | + lstatSync(path); |
| 69 | + return UNRESOLVABLE; |
| 70 | + } catch { |
| 71 | + // Doesn't exist at all — fall through to the nearest-ancestor walk. |
| 72 | + } |
51 | 73 | const parent = dirname(path); |
52 | 74 | if (parent === path) return path; |
53 | 75 | // Root (e.g. "/") already ends in the separator, so slicing past |
54 | 76 | // parent.length alone lands on the tail; anywhere else the separator |
55 | 77 | // between parent and tail must be skipped too. |
56 | 78 | const tailStart = parent.endsWith(sep) ? parent.length : parent.length + 1; |
57 | | - return join(realpathNearestOr(parent), path.slice(tailStart)); |
| 79 | + const parentReal = realpathNearestOr(parent); |
| 80 | + if (parentReal === UNRESOLVABLE) return UNRESOLVABLE; |
| 81 | + return join(parentReal, path.slice(tailStart)); |
58 | 82 | } |
59 | 83 | } |
60 | 84 |
|
@@ -93,6 +117,7 @@ export function resolveWorkspacePath( |
93 | 117 | const abs = resolve(cwd, path); |
94 | 118 | const realCwd = realpathOr(resolve(cwd)); |
95 | 119 | const real = realpathNearestOr(abs); |
| 120 | + if (real === UNRESOLVABLE) return undefined; |
96 | 121 | if (real === realCwd || real.startsWith(realCwd + sep)) return real; |
97 | 122 | if (inKnownRoots(real, rootsProvider())) return real; |
98 | 123 | if (inKnownRoots(real, rootsProvider(true))) return real; |
@@ -139,6 +164,7 @@ function underRoot(abs: string, root: string): boolean { |
139 | 164 | // unresolved while the abs path is rebuilt through an existing ancestor). |
140 | 165 | const realRoot = realpathNearestOr(root); |
141 | 166 | const realAbs = realpathNearestOr(abs); |
| 167 | + if (realRoot === UNRESOLVABLE || realAbs === UNRESOLVABLE) return false; |
142 | 168 | return realAbs === realRoot || realAbs.startsWith(realRoot + sep); |
143 | 169 | } |
144 | 170 |
|
|
0 commit comments