返回 CodeWhale
canonical-path.ts
根目录 / crates / tui / extension-host / src / dsh / canonical-path.ts
1 /**
2 * One canonical-path rule for the reviewed-closure checks.
3 *
4 * Windows: JS `realpathSync` lstats every ancestor starting at the drive root,
5 * and a Windows LPAC (AppContainer) host cannot read `C:\` (EPERM).
6 * `realpathSync.native` asks the OS for the opened file's final path
7 * (GetFinalPathNameByHandle) and needs access to that file alone. Its spelling
8 * differs from the caller's: a `\\?\` prefix, long names in place of 8.3 short
9 * names (`RUNNER~1`), drive-letter and on-disk case. So a canonical path is
10 * never compared with a raw one. Both sides go through `canonicalPath`, then
11 * `pathKey`, and containment is `relative(canonical root, canonical target)`.
12 *
13 * Under LPAC, `realpathSync.native` still fails with EPERM on some paths —
14 * especially directories such as the reviewed `source/` root — because the
15 * open uses FILE_FLAG_BACKUP_SEMANTICS. Core already refused links while
16 * granting that tree, so on EPERM/EACCES we keep a stable stripped spelling
17 * instead of throwing and aborting admitReviewedClosure. Link refusal for
18 * files still goes through the same helper when native succeeds.
19 *
20 * Elsewhere `realpathSync` is unchanged.
21 */
22 import { realpathSync } from 'node:fs'
23 import { posix, win32 } from 'node:path'
24
25 type Platform = NodeJS.Platform
26
27 export function canonicalPath(path: string, platform: Platform = process.platform): string {
28 if (platform !== 'win32') return realpathSync(path)
29 try {
30 return stripVerbatim(realpathSync.native(path), platform)
31 } catch (error) {
32 const code = error && typeof error === 'object' && 'code' in error ? (error as { code?: string }).code : undefined
33 // LPAC cannot open some granted paths the way native realpath requires
34 // (notably directories). Fall back to a stable spelling; Core's grant
35 // already refused links/reparse points in the admitted tree.
36 if (code === 'EPERM' || code === 'EACCES') {
37 return stripVerbatim(win32.normalize(path), platform)
38 }
39 throw error
40 }
41 }
42
43 /** Drop the Win32 verbatim prefix: `\\?\C:\x` → `C:\x`, `\\?\UNC\h\s` → `\\h\s`. Case is kept. */
44 export function stripVerbatim(path: string, platform: Platform = process.platform): string {
45 if (platform !== 'win32') return path
46 if (/^[\\/]{2}\?[\\/]UNC[\\/]/i.test(path)) return `\\\\${path.slice(8)}`
47 if (/^[\\/]{2}\?[\\/]/.test(path)) return path.slice(4)
48 return path
49 }
50
51 /** The identity used for equality: normalized and, on Windows, case-folded. Never joined or displayed. */
52 export function pathKey(path: string, platform: Platform = process.platform): string {
53 if (platform !== 'win32') return posix.normalize(path)
54 return win32.normalize(stripVerbatim(path, platform)).toLowerCase()
55 }
56
57 export function samePath(a: string, b: string, platform: Platform = process.platform): boolean {
58 return pathKey(a, platform) === pathKey(b, platform)
59 }
60
61 /**
62 * The `/`-separated path of `target` inside `root`, or `undefined` when it is
63 * outside. Both arguments must be spelled the same way (both raw, or both
64 * from `canonicalPath`). `''` is the root itself, which is never a file key.
65 * Win32 `relative` compares case-insensitively and keeps `target`'s case.
66 */
67 export function insideKey(root: string, target: string, platform: Platform = process.platform): string | undefined {
68 const path = platform === 'win32' ? win32 : posix
69 const inside = path.relative(stripVerbatim(root, platform), stripVerbatim(target, platform))
70 if (inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return undefined
71 return platform === 'win32' ? inside.split(win32.sep).join('/') : inside
72 }
73
74 /**
75 * True when `target` names `root/key` with no symbolic link (or junction)
76 * inside the root: its canonical path is the canonical root joined with the
77 * reviewed key. Links above the root are the root's own location and are
78 * absorbed by canonicalizing it. A missing or unreadable target is false.
79 */
80 export function isUnlinkedInside(root: string, key: string, target: string, platform: Platform = process.platform): boolean {
81 let canonicalRoot: string, canonicalTarget: string
82 try {
83 canonicalRoot = canonicalPath(root, platform)
84 canonicalTarget = canonicalPath(target, platform)
85 } catch {
86 return false
87 }
88 return unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform)
89 }
90
91 /** Pure half of `isUnlinkedInside`, over already-canonical paths. */
92 export function unlinkedKeyMatches(canonicalRoot: string, key: string, canonicalTarget: string, platform: Platform = process.platform): boolean {
93 if (!key || key.split('/').some((part) => !part || part === '.' || part === '..')) return false
94 const path = platform === 'win32' ? win32 : posix
95 return samePath(path.join(stripVerbatim(canonicalRoot, platform), ...key.split('/')), canonicalTarget, platform)
96 }
97
97 lines TYPESCRIPT