|
1 | | -import { lstatSync, realpathSync } from "node:fs"; |
| 1 | +import { lstatSync, readlinkSync, 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"; |
@@ -82,6 +82,52 @@ export function realpathNearestOr(path: string): string { |
82 | 82 | } |
83 | 83 | } |
84 | 84 |
|
| 85 | +const MAX_SYMLINK_HOPS = 40; |
| 86 | + |
| 87 | +// The deepest path-or-ancestor that is a symlink whose target does not exist. |
| 88 | +function danglingLinkOf(path: string): string | undefined { |
| 89 | + let current = path; |
| 90 | + for (;;) { |
| 91 | + try { |
| 92 | + if (lstatSync(current).isSymbolicLink()) { |
| 93 | + try { |
| 94 | + realpathSync(current); |
| 95 | + } catch { |
| 96 | + return current; |
| 97 | + } |
| 98 | + } |
| 99 | + } catch { |
| 100 | + // Component absent: keep walking up. |
| 101 | + } |
| 102 | + const parent = dirname(current); |
| 103 | + if (parent === current) return undefined; |
| 104 | + current = parent; |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +// realpathNearestOr reports a dangling symlink as UNRESOLVABLE because the |
| 109 | +// link's own name says nothing about where a write would land. A write |
| 110 | +// through `link -> .env` creates `.env`, so security judgments that need the |
| 111 | +// landing path follow the link targets by hand. Still UNRESOLVABLE for a |
| 112 | +// symlink loop or an unreadable link. |
| 113 | +export function realpathFollowingDangling(path: string): string { |
| 114 | + let current = path; |
| 115 | + for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) { |
| 116 | + const real = realpathNearestOr(current); |
| 117 | + if (real !== UNRESOLVABLE) return real; |
| 118 | + const link = danglingLinkOf(current); |
| 119 | + if (link === undefined) return UNRESOLVABLE; |
| 120 | + let target: string; |
| 121 | + try { |
| 122 | + target = resolve(dirname(link), readlinkSync(link)); |
| 123 | + } catch { |
| 124 | + return UNRESOLVABLE; |
| 125 | + } |
| 126 | + current = join(target, current.slice(link.length)); |
| 127 | + } |
| 128 | + return UNRESOLVABLE; |
| 129 | +} |
| 130 | + |
85 | 131 | // An empty root must never reach the prefix compare: `"" + sep` is just |
86 | 132 | // `sep` (e.g. "/" on Unix), which every absolute path starts with, turning |
87 | 133 | // containment into allow-all. A root of exactly `sep` itself is not this bug |
|
0 commit comments