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');
+ });
+ });
+});