diff --git a/.prettierignore b/.prettierignore index 1a908207..1ac80049 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,7 @@ lib-es5/ prelude/sea-bootstrap.bundle.js # Symlink needed for test test/test-99-#108/lib/log.js +# Built by test-99-#295/main.js at test time, removed again afterwards +test/test-99-#295/lib +test/test-99-#295/reallib/inner.js +test/test-99-#295/linkinfo.json diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cdacdda6..4f03e3e1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -178,7 +178,7 @@ Each file is stored with one or more store types: ### Runtime Bootstrap -`prelude/bootstrap.js` (1970 lines) executes before user code. It: +`prelude/bootstrap.js` (2066 lines) executes before user code. It: 1. **Sets up entrypoint** — Reads `DEFAULT_ENTRYPOINT` from injected parameters, sets `process.argv[1]` 2. **Initializes VFS** — Builds in-memory lookup from `VIRTUAL_FILESYSTEM` dictionary with optional path compression via `DICT` @@ -394,14 +394,15 @@ ASCII version: The `SEAProvider` (in `prelude/sea-vfs-setup.js`) implements lazy loading from a single archive blob: -| Method | Behavior | -| -------------------------- | ----------------------------------------------------------------------------- | -| `readFileSync(path)` | Resolve symlinks, `subarray()` from archive via `offsets` map, cache in `Map` | -| `statSync(path)` | Return metadata from manifest `stats` | -| `internalModuleStat(path)` | Fast path for module resolution: returns 0 (file), 1 (dir), or -2 (not found) | -| `readdirSync(path)` | Return directory entries from manifest `directories` | -| `existsSync(path)` | O(1) check against manifest `stats` | -| `readlinkSync(path)` | Return symlink target from manifest, fall back to `super.readlinkSync()` | +| Method | Behavior | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `readFileSync(path)` | Resolve symlinks, `subarray()` from archive via `offsets` map, cache in `Map` | +| `statSync(path)` | Return metadata from manifest `stats` | +| `internalModuleStat(path)` | Fast path for module resolution: returns 0 (file), 1 (dir), or -2 (not found) | +| `readdirSync(path)` | Return directory entries from manifest `directories` | +| `existsSync(path)` | O(1) check against manifest `stats` | +| `readlinkSync(path)` | Return symlink target from manifest, resolving a symlinked parent first, then fall back to `super.readlinkSync()`. Not reached via `fs.readlinkSync` — the VFS polyfill answers that through `realpathSync` (yao-pkg/pkg#299) | +| `realpathSync(path)` | Follow the symlink chain, then return the path if the manifest has it. Load-bearing: without it every archive path raises `ENOENT`, which also breaks `fs.readlinkSync` | The entire archive is loaded once via `sea.getRawAsset('__pkg_archive__')` which returns a zero-copy `ArrayBuffer` reference to the executable's memory-mapped region. Individual files are extracted via `Buffer.subarray(offset, offset + length)` using the manifest's `offsets` map, then cached in a `Map` on first access. String results (when `encoding` is specified) are derived directly from the archive view; Buffer results are copied to prevent callers from corrupting the shared archive memory. @@ -467,7 +468,7 @@ This keeps the VFS setup, shared patches, worker interception, and diagnostics a ## Shared Runtime Code -`prelude/bootstrap-shared.js` (~438 lines) contains runtime patches used by both bootstraps: +`prelude/bootstrap-shared.js` (~813 lines) contains runtime patches used by both bootstraps: ### Injection Mechanisms @@ -502,6 +503,30 @@ This keeps the VFS setup, shared patches, worker interception, and diagnostics a - Set `PKG_EXECPATH` env var so child processes can detect they were spawned from a packaged app - Replace references to `node`, `process.argv[0]`, or the entrypoint with `process.execPath` (the actual executable) +**`makeSymlinkResolver(symlinks, sep)`** — Builds the symlink resolver used by **both** modes: the traditional bootstrap (`findVirtualFileSystemKeyAndFollowLinks`) and the SEA provider (`SEAProvider._resolveSymlink`). It returns a function mapping a virtual path onto what its symlinks point at, walking parent components the way POSIX does — so a link at `node_modules/@scope/lib` also resolves `node_modules/@scope/lib/package.json` (#295). + +An empty `symlinks` record yields the identity function, so a symlink-free binary pays nothing. Otherwise the resolver precomputes which path depths can host a symlink key and memoises each key's fully resolved target — the memo is keyed by manifest entry, not by the caller's path, so it stays bounded by the manifest however many paths are looked up. A manifest cycle raises `ELOOP` rather than hanging startup, with libuv's platform errno (`-4067` on Windows, `-40` elsewhere) and the caller's syscall name. + +Matching is **longest-prefix-wins**, not first-match-in-insertion-order: when both `/lib` and `/lib/sub` are keys, the deeper one describes the whole chain while the shallower one would strand the walk on a path the archive has no entry for. That differs from POSIX's leftmost-first walk, and the two agree only because every target the walker records is already a full realpath (`toNormalizedRealPath`), so no component of a target can itself be a key. + +### Symlink semantics in packaged binaries + +Both bootstraps resolve symlinks on parent path components, so `require`, `fs.readFile` and friends reach files under a linked directory. Where they differ: + +| | Traditional | Enhanced SEA | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `readdir({ withFileTypes: true })` | Reports a snapshot symlink as a link (`isSymbolicLink()` true, `isDirectory()`/`isFile()` false), matching real `readdir` | Listing comes from `manifest.directories`, which holds resolved paths only, so link entries are not surfaced | +| `lstat` | Describes the link itself, agreeing with the dirent above | Follows the link | +| `readlink` | Returns the target from `SYMLINKS`; `EINVAL` for a path that exists but is not a link | Answered by the VFS polyfill through `realpathSync` (yao-pkg/pkg#299) | +| `realpath` | Follows the chain | Follows the chain | + +> **Breaking change (traditional mode, since #296).** `readdir({ withFileTypes: true })` previously reported every snapshot entry as a plain file or directory — `Dirent.isSymbolicLink()` took an argument it is never called with, so it always returned `false`. It now reports links as links, which is what Node does outside a packaged binary. Two consequences for packaged apps whose snapshot contains symlinks (pnpm and workspace trees most of all, plus `node_modules/.bin`): +> +> - Recursive walkers that gate descent on `isDirectory()` and skip links by default (glob, fast-glob, readdirp, `fs.cp` with `recursive`) no longer descend into a symlinked directory unless told to follow links. +> - A filter like `entries.filter((e) => e.isFile())` no longer matches a symlinked **file** — `node_modules/.bin/*` is the common case. +> +> Both match unpackaged Node. `fs.readlink` and `fs.lstat` were patched in the same change so that code taking the `isSymbolicLink()` branch is served rather than falling through to the host filesystem. + **`setupProcessPkg(entrypoint)`** — Creates the `process.pkg` compatibility object with `entrypoint`, `defaultEntrypoint`, and `path.resolve()`. **`installDiagnostic(snapshotPrefix)`** — Installs runtime diagnostics triggered by the `DEBUG_PKG` environment variable. Available in both traditional and SEA modes. The implementation lives in `prelude/bootstrap-shared.js` and is always present in the runtime bootstrap, but it is **only invoked when the binary was built with `--debug` / `-d`** — release builds omit the entrypoint call, so the diagnostic handler never runs and cannot expose the VFS tree contents. @@ -619,11 +644,11 @@ With `node:vfs` and `"useVfs": true` in the SEA config, assets will be auto-moun | File | Lines | Purpose | | -------------------------------- | ----- | -------------------------------------------------------------------------------------------- | -| `prelude/bootstrap.js` | ~1970 | Traditional runtime bootstrap (fs/module/process patching) | -| `prelude/bootstrap-shared.js` | ~486 | Shared runtime patches (dlopen, child_process, process.pkg, diagnostics) | +| `prelude/bootstrap.js` | ~2066 | Traditional runtime bootstrap (fs/module/process patching) | +| `prelude/bootstrap-shared.js` | ~813 | Shared runtime patches (dlopen, child_process, process.pkg, diagnostics, symlink resolution) | | `prelude/sea-bootstrap.js` | ~74 | CJS wrapper: Module.runMain() (CJS) or vm.Script + USE_MAIN_CONTEXT_DEFAULT_LOADER (ESM/TLA) | | `prelude/sea-bootstrap-core.js` | ~121 | Shared setup: VFS, patches, worker interception, diagnostics, perf start | -| `prelude/sea-vfs-setup.js` | ~469 | SEA VFS core: SEAProvider, archive loading, VFS mount, Windows patches | +| `prelude/sea-vfs-setup.js` | ~609 | SEA VFS core: SEAProvider, archive loading, VFS mount, Windows patches | | `prelude/sea-worker-entry.js` | ~11 | Worker thread entry: requires sea-vfs-setup.js for VFS in workers | | `scripts/build-sea-bootstrap.js` | ~50 | Build script: 2-step esbuild bundling (worker string + CJS main) | | `lib/index.ts` | ~704 | CLI entry point, mode routing | diff --git a/prelude/bootstrap-shared.js b/prelude/bootstrap-shared.js index 044b490e..f9cf6257 100644 --- a/prelude/bootstrap-shared.js +++ b/prelude/bootstrap-shared.js @@ -622,6 +622,184 @@ function installDiagnostic(snapshotPrefix) { } } +// ///////////////////////////////////////////////////////////////// +// SYMLINK PROCESSING ////////////////////////////////////////////// +// ///////////////////////////////////////////////////////////////// + +// Matches the typical Linux SYMLOOP_MAX. Bounds symlink resolution so a +// manifest cycle (or a corrupt manifest) cannot hang startup. +var MAX_SYMLINK_DEPTH = 40; + +// libuv gives ELOOP a different number on Windows (uv/errno.h: UV__ELOOP is +// -4067 there, -40 everywhere else). Same positive-constant, negated-at-use +// convention as bootstrap.js's own error codes. +var ELOOP = process.platform === 'win32' ? 4067 : 40; + +// Marks a symlink key whose resolution is still on the stack, so a cycle +// (/a -> /b -> /a, or /a -> /a/b) is caught instead of recursing forever. +var RESOLVING = {}; + +/** + * Build a symlink resolver over a manifest's symlinks record. + * + * The returned function maps a virtual path onto what its symlinks point at, + * following parent components the way POSIX does: `node_modules/@x/y` being a + * link makes `node_modules/@x/y/package.json` resolve too (#295). + * + * This runs before every fs operation inside a packaged binary (~30K times at + * startup on a large project). The empty-manifest case is allocation-free; the + * no-match case costs one `slice` per depth that actually hosts a key, not one + * per path component (see `depthHasKey` below). + * + * The returned resolver keeps hop-accounting state in its closure, so it is not + * reentrant — never call it from inside its own resolution. + */ +function makeSymlinkResolver(symlinks, sep) { + var keys = Object.keys(symlinks || {}); + + // Nothing to resolve: hand back identity, so no caller needs a guard of its + // own and a symlink-free binary pays nothing. + if (keys.length === 0) { + return function (p) { + return p; + }; + } + + // Symlink keys sit at a handful of depths — a package manager's links all + // live at the same level of node_modules. Recording which separator counts + // can host a key lets the walk below slice only at those depths and stop + // past the deepest one: for a 15-segment path in a tree whose links live at + // depth 4, that is one probe instead of fifteen. + var depthHasKey = []; + var maxDepth = 0; + for (var i = 0; i < keys.length; i++) { + var depth = 0; + var at = keys[i].indexOf(sep, 1); + while (at > 0) { + depth++; + at = keys[i].indexOf(sep, at + 1); + } + depthHasKey[depth] = true; + if (depth > maxDepth) maxDepth = depth; + } + + // Symlink key -> { target, cost }: where the key fully resolves to, and how + // many hops that took. Keyed by manifest entry rather than by the caller's + // path, so the map stays bounded by the manifest no matter how many distinct + // paths are looked up — including ones an application derives from untrusted + // input. It also amortizes across siblings: every file under one linked + // directory reuses a single entry. + var resolved = new Map(); + + // High-water hop count of the resolution currently in flight. follow() reads + // it to record each key's `cost`, so a cache hit can charge the hops the + // collapsed chain stands for instead of getting them for free — otherwise + // MAX_SYMLINK_DEPTH would depend on which path happened to be looked up + // first. + var deepest = 0; + + // Syscall reported by any ELOOP raised by the resolution in flight. Held in + // the closure rather than threaded through resolve()/follow(), which are on + // the startup hot path. + var syscall = 'stat'; + + function eloop(origin) { + var err = new Error( + 'ELOOP: too many symbolic links encountered, ' + + syscall + + " '" + + origin + + "'", + ); + err.code = 'ELOOP'; + err.errno = -ELOOP; + err.syscall = syscall; + err.path = origin; + return err; + } + + function follow(key, origin, hops) { + var cached = resolved.get(key); + if (cached !== undefined) { + if (cached === RESOLVING) throw eloop(origin); + var reached = hops + cached.cost; + if (reached > MAX_SYMLINK_DEPTH) throw eloop(origin); + if (reached > deepest) deepest = reached; + return cached.target; + } + resolved.set(key, RESOLVING); + // Restart the high-water mark at this key's depth so `cost` measures this + // subtree alone, then fold it back into the caller's mark on the way out. + var outer = deepest; + deepest = hops; + var target; + try { + target = resolve(symlinks[key], origin, hops + 1); + } catch (e) { + // Don't leave the sentinel behind, or a caught ELOOP would poison this + // key for every later lookup. + resolved.delete(key); + throw e; + } + resolved.set(key, { target: target, cost: deepest - hops }); + if (outer > deepest) deepest = outer; + return target; + } + + function resolve(p, origin, hops) { + if (hops > MAX_SYMLINK_DEPTH) throw eloop(origin); + if (hops > deepest) deepest = hops; + + // Longest prefix wins. The walker keys every entry on the *unresolved* + // path it walked (`appendSymlink` in lib/walker.ts) and each target is + // already fully realpath'd, so the deepest key describes the whole chain + // while a shallower one would strand the walk on a path the archive has no + // entry for. `/lib` and `/lib/sub` can both be keys. An exact + // match is just the deepest case, so it short-circuits the scan below. + // + // Deepest-prefix-first is not POSIX's leftmost-first, and the two agree + // only because every target the walker records is already a full realpath + // (`toNormalizedRealPath` in lib/walker.ts), so no component of a target + // can itself be a key. A hand-written manifest that breaks that invariant + // would resolve differently here than on disk. + if (typeof symlinks[p] === 'string') return follow(p, origin, hops); + + var bestPos = -1; + var bestKey = null; + var pos = p.indexOf(sep, 1); + var depth = 0; + while (pos > 0 && depth <= maxDepth) { + if (depthHasKey[depth]) { + var prefix = p.slice(0, pos); + // typeof, not truthiness: the record is JSON-derived and read with a + // bracket index, so `__proto__`/`constructor`/`toString` would + // otherwise match on an inherited, non-string value. + if (typeof symlinks[prefix] === 'string') { + bestPos = pos; + bestKey = prefix; + } + } + pos = p.indexOf(sep, pos + 1); + depth++; + } + + if (bestKey === null) return p; + + var target = follow(bestKey, origin, hops); + // Drop the remainder's leading separator when the target already ends in + // one, so the join cannot double up. + var rest = target.endsWith(sep) ? p.slice(bestPos + 1) : p.slice(bestPos); + // The remainder may hold links of its own, so walk the result. + return resolve(target + rest, origin, hops + 1); + } + + return function (p, forSyscall) { + deepest = 0; + syscall = forSyscall || 'stat'; + return resolve(p, p, 0); + }; +} + module.exports = { patchDlopen: patchDlopen, patchChildProcess: patchChildProcess, @@ -631,4 +809,5 @@ module.exports = { COMPRESS_NONE: COMPRESS_NONE, pickDecompressorSync: pickDecompressorSync, pickDecompressorAsync: pickDecompressorAsync, + makeSymlinkResolver: makeSymlinkResolver, }; diff --git a/prelude/bootstrap.js b/prelude/bootstrap.js index e8e5ad8f..dfade757 100644 --- a/prelude/bootstrap.js +++ b/prelude/bootstrap.js @@ -231,25 +231,15 @@ function toOriginal(fShort) { .join(path.sep); } -const symlinksEntries = Object.entries(SYMLINKS); - // separator for substitution depends on platform; const sepsep = DOCOMPRESS ? separator : path.sep; +// The resolver owns the no-symlink fast path and its own memoisation, so +// there is nothing to guard here. +const resolveSymlink = REQUIRE_SHARED.makeSymlinkResolver(SYMLINKS, sepsep); + function findVirtualFileSystemKeyAndFollowLinks(path_) { - let vfsKey = findVirtualFileSystemKey(path_, path.sep); - let needToSubstitute = true; - while (needToSubstitute) { - needToSubstitute = false; - for (const [k, v] of symlinksEntries) { - if (vfsKey.startsWith(`${k}${sepsep}`) || vfsKey === k) { - vfsKey = vfsKey.replace(k, v); - needToSubstitute = true; - break; - } - } - } - return vfsKey; + return resolveSymlink(findVirtualFileSystemKey(path_, path.sep)); } function realpathFromSnapshot(path_) { @@ -498,6 +488,8 @@ function payloadFileSync(pointer) { readdir: fs.readdir, realpathSync: fs.realpathSync, realpath: fs.realpath, + readlinkSync: fs.readlinkSync, + readlink: fs.readlink, statSync: fs.statSync, stat: fs.stat, lstatSync: fs.lstatSync, @@ -524,6 +516,7 @@ function payloadFileSync(pointer) { const ENOTDIR = windows ? 4052 : 20; const ENOENT = windows ? 4058 : 2; const EISDIR = windows ? 4068 : 21; + const EINVAL = windows ? 4071 : 22; function assertEncoding(encoding) { if (encoding && !Buffer.isEncoding(encoding)) { @@ -558,6 +551,18 @@ function payloadFileSync(pointer) { return error; } + function error_EINVAL(syscall, path_) { + const error = new Error( + `EINVAL: invalid argument, ${syscall} '${stripSnapshot(path_)}'`, + ); + error.errno = -EINVAL; + error.code = 'EINVAL'; + error.syscall = syscall; + error.path = path_; + error.pkg = true; + return error; + } + function error_ENOTDIR(path_) { const error = new Error(`ENOTDIR: not a directory, scandir '${path_}'`); error.errno = -ENOTDIR; @@ -1104,13 +1109,30 @@ function payloadFileSync(pointer) { Dirent.prototype.isSocket = noop; Dirent.prototype.isFIFO = noop; - Dirent.prototype.isSymbolicLink = (fileOrFolderName) => - Boolean(SYMLINKS[fileOrFolderName]); + // fs.Dirent.isSymbolicLink() takes no argument, so the link status has to be + // baked into the dirent at construction. 3 is UV_DIRENT_LINK, matching the + // type real readdir({ withFileTypes: true }) reports — it lstats, so a link + // is a link rather than the file or directory it points at. + Dirent.prototype.isSymbolicLink = function isSymbolicLink() { + return this.type === 3; + }; function getFileTypes(path_, entries) { return entries.map((entry) => { const ff = path.join(path_, entry); - const entity = findVirtualFileSystemEntry(ff); + // SYMLINKS is keyed by the *unresolved* vfs key, so this asks whether + // this entry is itself a link — not whether its target is one. It runs + // before the entity lookup, which follows links: an entry whose target + // is missing from the snapshot is still a link, and answering + // `undefined` there would put a hole in the readdir array. + // typeof, not truthiness: the record is read with a bracket index, so a + // key like `constructor` would otherwise match an inherited value. + const vfsKey = findVirtualFileSystemKey(ff, path.sep); + if (typeof SYMLINKS[vfsKey] === 'string') return new Dirent(entry, 3); + // Same lookup findVirtualFileSystemEntry() does, reusing the key above + // rather than rebuilding it — in DOCOMPRESS mode that is a full + // normalize+split+map+join per directory entry. + const entity = VIRTUAL_FILESYSTEM[resolveSymlink(vfsKey)]; if (!entity) return undefined; if (entity[STORE_BLOB] || entity[STORE_CONTENT]) return new Dirent(entry, 1); @@ -1265,6 +1287,56 @@ function payloadFileSync(pointer) { fs.realpathSync.native = fs.realpathSync; fs.realpath.native = fs.realpath; + // /////////////////////////////////////////////////////////////// + // readlink ////////////////////////////////////////////////////// + // /////////////////////////////////////////////////////////////// + + // readdir({ withFileTypes: true }) reports snapshot symlinks as links, so + // the usual `if (d.isSymbolicLink()) fs.readlinkSync(p)` pairing has to be + // answerable here — unpatched it would fall through to the host fs and + // ENOENT on a /snapshot path. + function readlinkFromSnapshot(path_) { + const vfsKey = findVirtualFileSystemKey(path_, path.sep); + const target = SYMLINKS[vfsKey]; + // typeof, not truthiness: the record is read with a bracket index. + if (typeof target === 'string') return toOriginal(target); + // Node answers EINVAL for a path that exists but is not a link, and + // ENOENT for one that does not exist at all. + if (VIRTUAL_FILESYSTEM[resolveSymlink(vfsKey)]) { + throw error_EINVAL('readlink', path_); + } + throw error_ENOENT('File or directory', path_); + } + + fs.readlinkSync = function readlinkSync(path_) { + if (!insideSnapshot(path_)) { + return ancestor.readlinkSync.apply(fs, arguments); + } + if (insideMountpoint(path_)) { + return ancestor.readlinkSync.apply(fs, translateNth(arguments, 0, path_)); + } + + return readlinkFromSnapshot(path_); + }; + + fs.readlink = function readlink(path_) { + if (!insideSnapshot(path_)) { + return ancestor.readlink.apply(fs, arguments); + } + if (insideMountpoint(path_)) { + return ancestor.readlink.apply(fs, translateNth(arguments, 0, path_)); + } + + const callback = dezalgo(maybeCallback(arguments)); + let target; + try { + target = readlinkFromSnapshot(path_); + } catch (error) { + return callback(error); + } + callback(null, target); + }; + // /////////////////////////////////////////////////////////////// // stat ////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////// @@ -1377,6 +1449,38 @@ function payloadFileSync(pointer) { // lstat ///////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////// + // lstat must describe the link itself rather than what it points at. The + // walker records every stat with fs.stat, so the stored isSymbolicLinkValue + // is false even for a link; SYMLINKS — keyed by the *unresolved* vfs key — + // is the only source of truth, and it is the same one readdir uses, so the + // two cannot disagree about an entry. + function asLink(s) { + s.isSymbolicLink = () => true; + s.isFile = noop; + s.isDirectory = noop; + return s; + } + + function lstatFromSnapshot(path_, cb) { + const vfsKey = findVirtualFileSystemKey(path_, path.sep); + // typeof, not truthiness: the record is read with a bracket index. + if (typeof SYMLINKS[vfsKey] !== 'string') { + return statFromSnapshot(path_, cb); + } + const entity = VIRTUAL_FILESYSTEM[vfsKey]; + const entityStat = entity && entity[STORE_STAT]; + // A link the walker recorded without its own stat entry: fall back rather + // than invent one. + if (!entityStat) return statFromSnapshot(path_, cb); + if (cb) { + return statFromSnapshotSub(entityStat, (error, s) => { + if (error) return cb(error); + cb(null, asLink(s)); + }); + } + return asLink(statFromSnapshotSub(entityStat)); + } + fs.lstatSync = function lstatSync(path_) { if (!insideSnapshot(path_)) { return ancestor.lstatSync.apply(fs, arguments); @@ -1385,7 +1489,7 @@ function payloadFileSync(pointer) { return ancestor.lstatSync.apply(fs, translateNth(arguments, 0, path_)); } - return statFromSnapshot(path_); + return lstatFromSnapshot(path_); }; fs.lstat = function lstat(path_) { @@ -1397,7 +1501,7 @@ function payloadFileSync(pointer) { } const callback = dezalgo(maybeCallback(arguments)); - statFromSnapshot(path_, callback); + lstatFromSnapshot(path_, callback); }; // /////////////////////////////////////////////////////////////// @@ -1549,6 +1653,7 @@ function payloadFileSync(pointer) { realpath: fs.promises.realpath, stat: fs.promises.stat, lstat: fs.promises.lstat, + readlink: fs.promises.readlink, fstat: fs.promises.fstat, access: fs.promises.access, copyFile: fs.promises.copyFile, @@ -1604,13 +1709,13 @@ function payloadFileSync(pointer) { fs.promises.read = util.promisify(fs.read); fs.promises.realpath = util.promisify(fs.realpath); + fs.promises.readlink = util.promisify(fs.readlink); fs.promises.fstat = util.promisify(fs.fstat); fs.promises.statfs = util.promisify(fs.statfs); fs.promises.access = util.promisify(fs.access); // TODO: all promises methods that try to edit files in snapshot should throw // TODO implement missing methods - // fs.promises.readlink ? // fs.promises.opendir ? } diff --git a/prelude/sea-vfs-setup.js b/prelude/sea-vfs-setup.js index 94a34ea5..98ab9f3c 100644 --- a/prelude/sea-vfs-setup.js +++ b/prelude/sea-vfs-setup.js @@ -24,10 +24,6 @@ try { var VirtualFileSystem = vfsModule.VirtualFileSystem; var MemoryProvider = vfsModule.MemoryProvider; -// Matches the typical Linux SYMLOOP_MAX. Bounds the symlink resolution -// loop so a manifest cycle (or a corrupt manifest) cannot hang startup. -var MAX_SYMLINK_DEPTH = 40; - // ///////////////////////////////////////////////////////////////// // PERFORMANCE INSTRUMENTATION ///////////////////////////////////// // ///////////////////////////////////////////////////////////////// @@ -151,6 +147,7 @@ var perf = { 'statSync calls', 'existsSync calls', 'readdirSync calls', + 'symlink resolutions', ]; counterOrder.forEach(function (label) { var v = self._counters[label]; @@ -283,13 +280,22 @@ function _makeStats(meta) { * * Performance design: * - * - internalModuleStat() O(1) manifest hash lookup (no tree walk). + * - internalModuleStat() Symlink resolution (no-op O(1) if the manifest has + * no symlinks; O(path depth) for any path that isn't itself symlinked, + * paid on every call — not memoised, since most lookups are one-off + * candidate paths and caching them would grow the cache unboundedly for + * no benefit; O(1) amortized only for paths that actually traverse a + * symlink, via a Map keyed by the manifest entry rather than by the + * caller's path — see makeSymlinkResolver() in bootstrap-shared.js) + * + O(1) manifest lookup. * This is the hottest path (~30K calls for large projects). * - * - statSync() O(1) manifest lookup + lightweight stat allocation. + * - statSync() Same symlink resolution as above + O(1) manifest + * lookup + lightweight stat allocation. * Not on the module resolution hot path. Returns a fresh object each call. * - * - existsSync() O(1) manifest lookup. + * - existsSync() Same symlink resolution as above + O(1) manifest + * lookup. * * - readFileSync() Zero-copy subarray from the archive with a Map * cache. Bypasses the MemoryProvider tree entirely. Returns a Buffer @@ -307,6 +313,11 @@ class SEAProvider extends MemoryProvider { this._manifest = seaManifest; this._fileCache = new Map(); + // One normalised symlinks record for every consumer below, so the + // resolver and readlinkSync cannot disagree about whether it may be absent. + this._symlinks = seaManifest.symlinks || {}; + this._resolve = shared.makeSymlinkResolver(this._symlinks, '/'); + // Pick the per-file decompressor once at construction time. Absent or 0 = // uncompressed archive (backward compat with pre-#250 SEA binaries). The // shared helper raises a uniformly-worded error when the host Node.js is @@ -336,27 +347,14 @@ class SEAProvider extends MemoryProvider { perf.end('directory tree init'); } - _resolveSymlink(p) { - // Fast path: the vast majority of lookups (~30K per startup on large - // projects) are not symlinks. A single object-has-key check avoids - // entering the loop and the i++/target fetch overhead for the common - // case. - var symlinks = this._manifest.symlinks; - if (symlinks[p] === undefined) return p; - var original = p; - for (var i = 0; i < MAX_SYMLINK_DEPTH; i++) { - var target = symlinks[p]; - if (!target) return p; - p = target; - } - var err = new Error( - "ELOOP: too many symbolic links encountered, '" + original + "'", - ); - err.code = 'ELOOP'; - err.errno = -40; - err.syscall = 'stat'; - err.path = original; - throw err; + _resolveSymlink(p, syscall) { + // The resolver owns the no-symlink fast path, so there is nothing to guard + // here. Counting only the calls that actually moved the path keeps the + // counter meaningful on symlink-free binaries, where it used to be skipped + // by a separate guard. + var resolved = this._resolve(p, syscall); + if (resolved !== p) perf.count('symlink resolutions'); + return resolved; } get fileCacheSize() { @@ -444,12 +442,47 @@ class SEAProvider extends MemoryProvider { } readlinkSync(filePath) { + // Not reached through fs.readlinkSync: the VFS polyfill answers readlink + // by way of realpathSync (findVFSForRealpath in @roberts_lando/vfs), so + // this serves direct provider callers only. Manifest targets are full + // realpaths (toNormalizedRealPath in lib/walker.ts), not the raw link body + // POSIX readlink would return, so what comes back is a resolved path. var p = toManifestKey(filePath); - var target = this._manifest.symlinks[p]; - if (target) return target; + var target = this._symlinks[p]; + if (typeof target === 'string') return target; + // A link keyed under its *resolved* parent instead is only reachable once + // that parent is followed — POSIX readlink resolves the parent and returns + // only the final component. Same gap as #295, which every sibling method + // closes via _resolveSymlink. + var slash = p.lastIndexOf('/'); + if (slash > 0) { + var parent = this._resolveSymlink(p.slice(0, slash), 'readlink'); + // Drop the remainder's leading separator when the resolved parent already + // ends in one, so the join cannot produce `//name` and silently miss. + var viaParent = + parent + (parent.endsWith('/') ? p.slice(slash + 1) : p.slice(slash)); + if (viaParent !== p) { + target = this._symlinks[viaParent]; + if (typeof target === 'string') return target; + p = viaParent; + } + } return super.readlinkSync(p); } + realpathSync(filePath) { + // The base class only knows the directory tree built in the constructor, + // so without this every archive file resolves to ENOENT — which also + // breaks fs.readlinkSync, since the VFS answers readlink by way of + // realpath. Following the symlink chain here is the whole point. + var p = this._resolveSymlink(toManifestKey(filePath), 'realpath'); + // Own-property truthiness, not `in`: the manifest is JSON-derived and read + // with a bracket index, so `in` would report `constructor`/`toString` as + // existing files. Matches every sibling lookup below. + if (this._manifest.stats[p]) return p; + return super.realpathSync(p); + } + statSync(filePath) { perf.count('statSync calls'); var p = this._resolveSymlink(toManifestKey(filePath)); @@ -477,7 +510,7 @@ class SEAProvider extends MemoryProvider { readdirSync(dirPath) { perf.count('readdirSync calls'); - var p = this._resolveSymlink(toManifestKey(dirPath)); + var p = this._resolveSymlink(toManifestKey(dirPath), 'scandir'); var entries = this._manifest.directories[p]; if (entries) return entries.slice(); return super.readdirSync(p); @@ -486,7 +519,7 @@ class SEAProvider extends MemoryProvider { existsSync(filePath) { perf.count('existsSync calls'); var p = this._resolveSymlink(toManifestKey(filePath)); - return p in this._manifest.stats; + return Boolean(this._manifest.stats[p]); } } diff --git a/test/test-99-#295/index.js b/test/test-99-#295/index.js new file mode 100644 index 00000000..1baf3d2c --- /dev/null +++ b/test/test-99-#295/index.js @@ -0,0 +1,90 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const log = require('./lib/log'); + +// `lib` links to `reallib`, and `reallib/inner.js` links to `log.js` inside +// it. The walker records both, so this path has its own manifest entry that +// must win over its symlinked parent. +require('./lib/inner.js'); + +// Windows can refuse to create the nested *file* link, in which case main.js +// leaves a plain copy in its place. The directory link is a real junction +// there, so the parent walk is covered either way. +const { nestedIsLink } = require('./linkinfo.json'); + +let isSea = false; +try { + isSea = require('node:sea').isSea(); +} catch { + isSea = false; +} + +const nested = path.join(__dirname, 'lib', 'inner.js'); + +// realpath must follow the chain rather than throwing ENOENT. +assert.strictEqual( + path.basename(fs.realpathSync(nested)), + nestedIsLink ? 'log.js' : 'inner.js', +); +assert.strictEqual( + path.basename(fs.realpathSync(path.join(__dirname, 'lib', 'log.js'))), + 'log.js', +); + +// Both modes answer readlink now: SEA by way of realpath through the VFS +// polyfill, classic from the SYMLINKS record (#296). +if (nestedIsLink) { + assert.strictEqual(path.basename(fs.readlinkSync(nested)), 'log.js'); +} + +// readlink on a path that exists but is not a link is EINVAL, not ENOENT. +// Classic mode only: in SEA mode the VFS polyfill answers readlink through +// realpathSync without ever consulting the provider, so a non-link returns a +// resolved path instead of throwing (yao-pkg/pkg#299, upstream routing). +if (!isSea) { + assert.throws(() => fs.readlinkSync(path.join(__dirname, 'index.js')), { + code: 'EINVAL', + }); +} + +// readdir must return a usable listing in both modes. SEA builds its listing +// from manifest.directories, which holds only the paths the walker recorded, +// so which entries appear there is not asserted — only that it works at all. +const dirents = fs.readdirSync(__dirname, { withFileTypes: true }); +assert.ok( + Array.isArray(dirents) && dirents.length > 0, + 'readdir returned nothing', +); + +// Classic-mode readdir is lstat-based, so a link reports as a link rather +// than as the directory it points at — same as it does outside a packaged +// binary — and lstat must agree with the dirent. The SEA provider builds its +// listing from manifest.directories, which holds resolved paths only, so it +// does not surface link entries at all. +if (!isSea) { + const libEntry = dirents.find((e) => e.name === 'lib'); + assert.ok(libEntry, 'lib missing from readdir'); + assert.strictEqual(libEntry.isSymbolicLink(), true); + assert.strictEqual(libEntry.isDirectory(), false); + const reallibEntry = dirents.find((e) => e.name === 'reallib'); + assert.ok(reallibEntry, 'reallib missing from readdir'); + assert.strictEqual(reallibEntry.isSymbolicLink(), false); + assert.strictEqual(reallibEntry.isDirectory(), true); + + // lstat describes the link itself; stat follows it. readdir and lstat must + // not contradict each other about the same entry. + const libPath = path.join(__dirname, 'lib'); + assert.strictEqual(fs.lstatSync(libPath).isSymbolicLink(), true); + assert.strictEqual(fs.lstatSync(libPath).isDirectory(), false); + assert.strictEqual(fs.statSync(libPath).isDirectory(), true); + assert.strictEqual(fs.statSync(libPath).isSymbolicLink(), false); + + // readlink round-trips the directory link too. + assert.strictEqual(path.basename(fs.readlinkSync(libPath)), 'reallib'); +} + +log(42); diff --git a/test/test-99-#295/main.js b/test/test-99-#295/main.js new file mode 100644 index 00000000..b46a4b1c --- /dev/null +++ b/test/test-99-#295/main.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const utils = require('../utils.js'); + +// Enhanced SEA requires Node.js >= 22 +if (utils.getNodeMajorVersion() < 22) { + return; +} + +assert(__dirname === process.cwd()); + +// The links are built here instead of being committed: git on Windows checks +// a committed symlink out as a text file holding its target, which would make +// pkg bytecode-compile `./log.js` as if it were source and fail the *build*. +// Building them at test time also lets Windows use a junction — the shape npm +// actually creates for workspace links, which is what #295 was reported on. +const libLink = path.join(__dirname, 'lib'); +const innerLink = path.join(__dirname, 'reallib', 'inner.js'); +const linkInfo = path.join(__dirname, 'linkinfo.json'); +const generated = [libLink, innerLink, linkInfo]; + +function removeGenerated() { + for (const p of generated) utils.vacuum.sync(p); +} + +removeGenerated(); + +fs.symlinkSync( + path.join(__dirname, 'reallib'), + libLink, + process.platform === 'win32' ? 'junction' : 'dir', +); + +// A *file* symlink needs Developer Mode or elevation on Windows. Fall back to +// a plain copy there: the directory junction still exercises the parent walk, +// and index.js relaxes the nested assertions to match. +let nestedIsLink = true; +try { + fs.symlinkSync('log.js', innerLink, 'file'); +} catch (error) { + if (process.platform !== 'win32') throw error; + fs.copyFileSync(path.join(__dirname, 'reallib', 'log.js'), innerLink); + nestedIsLink = false; +} +fs.writeFileSync(linkInfo, `${JSON.stringify({ nestedIsLink }, null, 2)}\n`); + +try { + const input = './package.json'; + const testName = 'test-99-#295'; + const standardOutput = 'test-output.exe'; + + const expectedOutput = '42\n'; + + const newcomers = utils.seaHostOutputs(testName).concat(standardOutput); + + const before = utils.filesBefore(newcomers); + + // SEA mode — the mode #295 was reported against. + utils.runSeaHostOnly(input, testName); + utils.assertSeaOutput(testName, expectedOutput); + + // Standard mode resolves symlinks through the same shared helper, so it + // needs the same fixture: bootstrap.js was rewritten onto that helper in + // #296 and would otherwise have no end-to-end coverage of the parent-symlink + // walk. + utils.pkg.sync(['--target', 'host', '--output', standardOutput, input]); + assert.strictEqual( + utils.spawn.sync(`./${standardOutput}`, []), + expectedOutput, + ); + + utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true }); +} finally { + removeGenerated(); +} diff --git a/test/test-99-#295/package.json b/test/test-99-#295/package.json new file mode 100644 index 00000000..33fd9e6e --- /dev/null +++ b/test/test-99-#295/package.json @@ -0,0 +1,6 @@ +{ + "name": "test-99-295", + "version": "1.0.0", + "main": "index.js", + "bin": "index.js" +} diff --git a/test/test-99-#295/reallib/log.js b/test/test-99-#295/reallib/log.js new file mode 100644 index 00000000..2e92b2d9 --- /dev/null +++ b/test/test-99-#295/reallib/log.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = console.log; diff --git a/test/test.js b/test/test.js index 87e6dad8..3541f2e7 100644 --- a/test/test.js +++ b/test/test.js @@ -85,6 +85,7 @@ const npmTests = [ 'test-91-sea-esm-entry', 'test-92-sea-tla', 'test-94-sea-esm-import-meta', + 'test-99-#295', ]; if (testFilter) { diff --git a/test/unit/resolve-symlink.test.ts b/test/unit/resolve-symlink.test.ts new file mode 100644 index 00000000..03ebe2de --- /dev/null +++ b/test/unit/resolve-symlink.test.ts @@ -0,0 +1,310 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { describe, it } from 'node:test'; + +const shared = createRequire(__filename)('../../prelude/bootstrap-shared.js'); +const makeSymlinkResolver = shared.makeSymlinkResolver as ( + _symlinks: Record, + _sep: string, +) => (_p: string, _syscall?: string) => string; + +// makeSymlinkResolver() backs both the classic bootstrap (prelude/bootstrap.js) +// and the SEA VFS provider (prelude/sea-vfs-setup.js) — see #295/#296. These +// are table-driven pure-logic tests against the shared implementation +// directly, requested during PR review as a complement to the e2e +// test-99-#295 (which only covers one level of symlink nesting end to end). +describe('makeSymlinkResolver', () => { + it('returns non-symlinked paths unchanged', () => { + const resolve = makeSymlinkResolver( + { '/snapshot/linked': '/snapshot/real' }, + '/', + ); + assert.equal(resolve('/snapshot/other/file.js'), '/snapshot/other/file.js'); + }); + + it('resolves an exact match (the path itself is the symlink)', () => { + const resolve = makeSymlinkResolver( + { '/snapshot/linked': '/snapshot/real' }, + '/', + ); + assert.equal(resolve('/snapshot/linked'), '/snapshot/real'); + }); + + it('resolves a nested path under a symlinked directory', () => { + const resolve = makeSymlinkResolver( + { '/snapshot/linked': '/snapshot/real' }, + '/', + ); + assert.equal( + resolve('/snapshot/linked/lib/deep/file.js'), + '/snapshot/real/lib/deep/file.js', + ); + }); + + it('resolves the deepest matching symlink (longest prefix wins)', () => { + // Two symlinks where one key is a literal prefix of the other. The walker + // keys entries on the path it walked, *before* resolution, and every + // target is already fully realpath'd — so the deeper key is the complete + // answer and taking the shallower one would strand the walk on a path the + // archive has no entry for. + const resolve = makeSymlinkResolver( + { + '/a': '/shallow-target', + '/a/b': '/deep-target', + }, + '/', + ); + assert.equal(resolve('/a/b/c'), '/deep-target/c'); + }); + + it('follows a directory symlink nested inside another one', () => { + // The real manifest shape behind the case above: `walker.appendSymlink` + // records `/sub` under the unresolved path because it descended + // through the `` link to reach it. Resolving the parent first would + // yield /app/reallib/sub/file.js, which the archive has no entry for. + const resolve = makeSymlinkResolver( + { + '/app/lib': '/app/reallib', + '/app/lib/sub': '/app/reallib/realsub', + }, + '/', + ); + assert.equal( + resolve('/app/lib/sub/file.js'), + '/app/reallib/realsub/file.js', + ); + // A sibling with no entry of its own still follows the parent link. + assert.equal(resolve('/app/lib/other.js'), '/app/reallib/other.js'); + }); + + it('prefers an exact entry over its symlinked parent', () => { + // The real manifest shape: the walker descends through a symlinked + // directory, so a link inside one gets its own key under the unresolved + // path. Both keys exist, and the exact (more specific) one must win — + // resolving through the parent instead would land on a path the archive + // has no entry for. Regression guard for test-99-#295/reallib/inner.js. + const resolve = makeSymlinkResolver( + { + '/app/lib': '/app/reallib', + '/app/lib/inner.js': '/app/reallib/log.js', + }, + '/', + ); + assert.equal(resolve('/app/lib/inner.js'), '/app/reallib/log.js'); + // A path with no exact entry still follows the symlinked parent. + assert.equal(resolve('/app/lib/sub/deep.js'), '/app/reallib/sub/deep.js'); + }); + + it('chains through multiple independent symlinks', () => { + const resolve = makeSymlinkResolver( + { + '/a': '/b', + '/b/c': '/d', + }, + '/', + ); + // /a/c/file.js -> (hop 1: /a -> /b) /b/c/file.js + // -> (hop 2: /b/c -> /d) /d/file.js + assert.equal(resolve('/a/c/file.js'), '/d/file.js'); + }); + + it('avoids a double separator when the target ends with one', () => { + // Regression case from review: a symlink whose target is the bare root. + const resolve = makeSymlinkResolver({ '/node_modules/@t/root': '/' }, '/'); + assert.equal( + resolve('/node_modules/@t/root/package.json'), + '/package.json', + ); + }); + + it('avoids a double separator for any target ending with a separator, not just root', () => { + const resolve = makeSymlinkResolver( + { '/snapshot/linked': '/snapshot/real/' }, + '/', + ); + assert.equal(resolve('/snapshot/linked/file.js'), '/snapshot/real/file.js'); + }); + + it('throws ELOOP on a cyclic manifest instead of hanging', () => { + const resolve = makeSymlinkResolver({ '/a': '/a/b' }, '/'); + assert.throws( + () => resolve('/a/x'), + (err: NodeJS.ErrnoException) => { + assert.equal(err.code, 'ELOOP'); + return true; + }, + ); + }); + + it('throws ELOOP on a cycle spanning two entries', () => { + const resolve = makeSymlinkResolver({ '/a': '/b/x', '/b': '/a' }, '/'); + assert.throws( + () => resolve('/a/f.js'), + (err: NodeJS.ErrnoException) => { + assert.equal(err.code, 'ELOOP'); + return true; + }, + ); + }); + + it('gives ELOOP the errno shape Node uses for the platform', () => { + // libuv numbers ELOOP differently on Windows (uv/errno.h: -4067 vs -40). + const expected = process.platform === 'win32' ? -4067 : -40; + const resolve = makeSymlinkResolver({ '/a': '/a/b' }, '/'); + assert.throws( + () => resolve('/a/x'), + (err: NodeJS.ErrnoException) => { + assert.equal(err.code, 'ELOOP'); + assert.equal(err.errno, expected); + assert.equal(err.path, '/a/x'); + return true; + }, + ); + }); + + it("reports the caller's syscall, defaulting to stat", () => { + const resolve = makeSymlinkResolver({ '/a': '/a/b' }, '/'); + assert.throws( + () => resolve('/a/x'), + (err: NodeJS.ErrnoException) => { + assert.equal(err.syscall, 'stat'); + assert.match(err.message, /^ELOOP: .*, stat '\/a\/x'$/); + return true; + }, + ); + assert.throws( + () => resolve('/a/x', 'realpath'), + (err: NodeJS.ErrnoException) => { + assert.equal(err.syscall, 'realpath'); + assert.match(err.message, /realpath '\/a\/x'$/); + return true; + }, + ); + }); + + it("does not leak the previous call's syscall into the next", () => { + const resolve = makeSymlinkResolver({ '/a': '/a/b' }, '/'); + assert.throws(() => resolve('/a/x', 'readlink'), { syscall: 'readlink' }); + assert.throws(() => resolve('/a/x'), { syscall: 'stat' }); + }); + + it('keeps throwing ELOOP on a repeat lookup', () => { + // The in-progress sentinel must not be left behind in the memo, or a + // caught ELOOP would poison unrelated later lookups. + const resolve = makeSymlinkResolver({ '/a': '/a/b' }, '/'); + assert.throws(() => resolve('/a/x'), { code: 'ELOOP' }); + assert.throws(() => resolve('/a/y'), { code: 'ELOOP' }); + }); + + describe('the MAX_SYMLINK_DEPTH bound', () => { + // Chain of `n` links ending at '/end': /l0 -> /l1 -> ... -> /ln -> /end. + const chain = (n: number) => { + const m: Record = {}; + for (let i = 0; i < n; i += 1) m[`/l${i}`] = `/l${i + 1}`; + m[`/l${n}`] = '/end'; + return m; + }; + + it('does not depend on which path was resolved first', () => { + // A cache hit hands back a target that stands for many hops. Those hops + // have to be charged back, or warming the tail of an over-long chain + // would let the head through the bound that a cold lookup rejects. + const cold = makeSymlinkResolver(chain(41), '/'); + assert.throws(() => cold('/l0'), { code: 'ELOOP' }); + + const warm = makeSymlinkResolver(chain(41), '/'); + warm('/l20'); + assert.throws(() => warm('/l0'), { code: 'ELOOP' }); + }); + + it('still resolves a chain that fits, warm or cold', () => { + const cold = makeSymlinkResolver(chain(30), '/'); + assert.equal(cold('/l0'), '/end'); + + const warm = makeSymlinkResolver(chain(30), '/'); + warm('/l15'); + assert.equal(warm('/l0'), '/end'); + }); + + it('charges each key only for its own hops', () => { + // '/end/short' is one hop, but it is first reached at the tail of a long + // chain. Billing it the whole chain's depth would make a later, shallow + // lookup through it blow the bound for no reason. + const symlinks: Record = chain(38); + symlinks['/end/short'] = '/y'; + symlinks['/p'] = '/q'; + symlinks['/q'] = '/r'; + symlinks['/r'] = '/end/short'; + const resolve = makeSymlinkResolver(symlinks, '/'); + assert.equal(resolve('/l0/short/f.js'), '/y/f.js'); + assert.equal(resolve('/p/f.js'), '/y/f.js'); + }); + }); + + it('is separator-agnostic (works with a non-"/" separator)', () => { + const resolve = makeSymlinkResolver( + { '\\snapshot\\linked': '\\snapshot\\real' }, + '\\', + ); + assert.equal( + resolve('\\snapshot\\linked\\file.js'), + '\\snapshot\\real\\file.js', + ); + }); + + describe('empty manifest', () => { + it('returns every path unchanged', () => { + const resolve = makeSymlinkResolver({}, '/'); + assert.equal(resolve('/snapshot/app/index.js'), '/snapshot/app/index.js'); + }); + + it('tolerates an absent symlinks record', () => { + const resolve = makeSymlinkResolver( + undefined as unknown as Record, + '/', + ); + assert.equal(resolve('/snapshot/app/index.js'), '/snapshot/app/index.js'); + }); + }); + + describe('inherited Object properties', () => { + // The manifest record is JSON-derived and read with a bracket index, so + // a path component that names an Object.prototype key must not match. + for (const key of ['__proto__', 'constructor', 'toString', 'valueOf']) { + it(`does not treat "${key}" as a symlink`, () => { + const resolve = makeSymlinkResolver({ '/snapshot/x': '/y' }, '/'); + assert.equal(resolve(`/${key}/file.js`), `/${key}/file.js`); + assert.equal(resolve(`/${key}`), `/${key}`); + }); + } + }); + + describe('memoisation', () => { + it('memoises the symlink hop, not the caller path', () => { + // Resolve one file, then mutate the manifest and resolve a *different* + // file under the same link. The stale target proves the memo is keyed + // on the manifest entry — a cache keyed on the full caller path would + // re-walk here, and would grow without bound on caller-supplied paths. + const symlinks: Record = { + '/snapshot/linked': '/snapshot/real', + }; + const resolve = makeSymlinkResolver(symlinks, '/'); + assert.equal(resolve('/snapshot/linked/a.js'), '/snapshot/real/a.js'); + + symlinks['/snapshot/linked'] = '/snapshot/changed'; + assert.equal(resolve('/snapshot/linked/b.js'), '/snapshot/real/b.js'); + }); + + it('gives each resolver its own memo', () => { + const symlinks: Record = { + '/snapshot/linked': '/snapshot/real', + }; + const first = makeSymlinkResolver(symlinks, '/'); + assert.equal(first('/snapshot/linked/a.js'), '/snapshot/real/a.js'); + + symlinks['/snapshot/linked'] = '/snapshot/changed'; + const second = makeSymlinkResolver(symlinks, '/'); + assert.equal(second('/snapshot/linked/a.js'), '/snapshot/changed/a.js'); + }); + }); +});