| 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 |