diff --git a/.gitignore b/.gitignore
index b57a3177f..e245e1b6f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,8 @@ examples/express/node_modules
yarn-error.log
tsconfig.tsbuildinfo
prelude/sea-bootstrap.bundle.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 cdacdda65..eb0c2f2f6 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` (1949 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 a negative errno (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()` |
+| `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 VFS mounts under a POSIX `/snapshot` prefix, so on Windows the result is converted back to `C:\snapshot\...` before it leaves `fs` (yao-pkg/pkg#305) |
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` (~791 lines) contains runtime patches used by both bootstraps:
### Injection Mechanisms
@@ -502,6 +503,12 @@ 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 chain longer than `MAX_SYMLINK_DEPTH` (or a cycle in a hand-edited manifest — the walker cannot emit one) raises `ELOOP` rather than hanging startup.
+
+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.
+
**`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 +626,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` | ~1949 | Traditional runtime bootstrap (fs/module/process patching) |
+| `prelude/bootstrap-shared.js` | ~791 | 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` | ~574 | 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 57377ebcf..d5f7bff57 100644
--- a/prelude/bootstrap-shared.js
+++ b/prelude/bootstrap-shared.js
@@ -632,6 +632,160 @@ function installDiagnostic(snapshotPrefix) {
}
}
+// /////////////////////////////////////////////////////////////////
+// SYMLINK PROCESSING //////////////////////////////////////////////
+// /////////////////////////////////////////////////////////////////
+
+// Matches the typical Linux SYMLOOP_MAX.
+var MAX_SYMLINK_DEPTH = 40;
+
+// libuv's errno numbers differ on Windows (uv/errno.h). Negated at use.
+var ERRNO = (function () {
+ var windows = process.platform === 'win32';
+ return {
+ ENOTDIR: windows ? 4052 : 20,
+ ENOENT: windows ? 4058 : 2,
+ EISDIR: windows ? 4068 : 21,
+ ELOOP: windows ? 4067 : 40,
+ };
+})();
+
+/**
+ * Error shape for a snapshot path: code, platform errno, syscall, path and the
+ * `pkg` marker. Callers keep their own message — classic mode's ENOENT carries
+ * pkg's "recompile adding it as asset" guidance.
+ */
+function makeFsError(message, code, syscall, path_) {
+ var err = new Error(message);
+ err.code = code;
+ err.errno = -ERRNO[code];
+ if (syscall !== undefined) err.syscall = syscall;
+ err.path = path_;
+ err.pkg = true;
+ return err;
+}
+
+// Memo value for a key whose resolution is still on the stack: hitting it
+// again means a cycle.
+var RESOLVING = {};
+
+/**
+ * Build a symlink resolver over a manifest's symlinks record.
+ *
+ * Maps a virtual path onto what its symlinks point at, following parent
+ * components the way POSIX does: a link at `node_modules/@x/y` also resolves
+ * `node_modules/@x/y/package.json` (#295). Runs before every snapshot fs call
+ * (~30K at startup on a large project). Not reentrant: hop accounting lives in
+ * the closure.
+ */
+function makeSymlinkResolver(symlinks, sep) {
+ var keys = Object.keys(symlinks || {});
+
+ // A symlink-free binary pays nothing.
+ if (keys.length === 0) {
+ return function (p) {
+ return p;
+ };
+ }
+
+ // Links sit at a few depths, so the scan below only slices at those.
+ 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 }. Keyed by manifest entry, not caller path,
+ // so it stays bounded by the manifest.
+ var resolved = new Map();
+
+ // High-water hop count of the resolution in flight. A memo hit charges the
+ // hops its chain took, so the depth bound does not depend on lookup order.
+ var deepest = 0;
+
+ function eloop(origin) {
+ return makeFsError(
+ "ELOOP: too many symbolic links encountered, stat '" + origin + "'",
+ 'ELOOP',
+ 'stat',
+ origin,
+ );
+ }
+
+ 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);
+ // Measure this key's subtree alone, then fold it back into the caller's.
+ var outer = deepest;
+ deepest = hops;
+ var target;
+ try {
+ target = resolve(symlinks[key], origin, hops + 1);
+ } catch (e) {
+ // A leftover sentinel would fail later lookups that fit the bound.
+ 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: `/lib` and `/lib/sub` can both be keys,
+ // and only the deeper one describes the whole chain. This matches POSIX
+ // because the walker records full realpaths as targets
+ // (`toNormalizedRealPath` in lib/walker.ts).
+ 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: `constructor` and friends are inherited, non-string values.
+ 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);
+ var rest = target.endsWith(sep) ? p.slice(bestPos + 1) : p.slice(bestPos);
+ // The remainder may hold links of its own.
+ return resolve(target + rest, origin, hops + 1);
+ }
+
+ return function resolveKey(p) {
+ deepest = 0;
+ return resolve(p, p, 0);
+ };
+}
+
module.exports = {
pkgCacheDir: pkgCacheDir,
patchDlopen: patchDlopen,
@@ -642,4 +796,7 @@ module.exports = {
COMPRESS_NONE: COMPRESS_NONE,
pickDecompressorSync: pickDecompressorSync,
pickDecompressorAsync: pickDecompressorAsync,
+ makeSymlinkResolver: makeSymlinkResolver,
+ ERRNO: ERRNO,
+ makeFsError: makeFsError,
};
diff --git a/prelude/bootstrap.js b/prelude/bootstrap.js
index 83682655c..93eb3fc78 100644
--- a/prelude/bootstrap.js
+++ b/prelude/bootstrap.js
@@ -231,25 +231,13 @@ function toOriginal(fShort) {
.join(path.sep);
}
-const symlinksEntries = Object.entries(SYMLINKS);
-
// separator for substitution depends on platform;
const sepsep = DOCOMPRESS ? separator : path.sep;
+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_) {
@@ -521,9 +509,9 @@ function payloadFileSync(pointer) {
const windows = process.platform === 'win32';
const docks = {};
- const ENOTDIR = windows ? 4052 : 20;
- const ENOENT = windows ? 4058 : 2;
- const EISDIR = windows ? 4068 : 21;
+ // One errno table for both preludes — see bootstrap-shared.js.
+ const makeFsError = REQUIRE_SHARED.makeFsError;
+ const ENOENT = REQUIRE_SHARED.ERRNO.ENOENT;
function assertEncoding(encoding) {
if (encoding && !Buffer.isEncoding(encoding)) {
@@ -536,35 +524,36 @@ function payloadFileSync(pointer) {
return typeof cb === 'function' ? cb : rethrow;
}
+ // The messages stay traditional-mode's own — this one carries pkg's
+ // "recompile adding it as asset" guidance and test-50-not-found-wording
+ // asserts it. Only the shape is shared.
function error_ENOENT(fileOrDirectory, path_) {
- const error = new Error(
+ return makeFsError(
`${fileOrDirectory} '${stripSnapshot(path_)}' ` +
`was not included into executable at compilation stage. ` +
`Please recompile adding it as asset or script.`,
+ 'ENOENT',
+ undefined,
+ path_,
);
- error.errno = -ENOENT;
- error.code = 'ENOENT';
- error.path = path_;
- error.pkg = true;
- return error;
}
function error_EISDIR(path_) {
- const error = new Error('EISDIR: illegal operation on a directory, read');
- error.errno = -EISDIR;
- error.code = 'EISDIR';
- error.path = path_;
- error.pkg = true;
- return error;
+ return makeFsError(
+ 'EISDIR: illegal operation on a directory, read',
+ 'EISDIR',
+ 'read',
+ path_,
+ );
}
function error_ENOTDIR(path_) {
- const error = new Error(`ENOTDIR: not a directory, scandir '${path_}'`);
- error.errno = -ENOTDIR;
- error.code = 'ENOTDIR';
- error.path = path_;
- error.pkg = true;
- return error;
+ return makeFsError(
+ `ENOTDIR: not a directory, scandir '${path_}'`,
+ 'ENOTDIR',
+ 'scandir',
+ path_,
+ );
}
// ///////////////////////////////////////////////////////////////
diff --git a/prelude/sea-vfs-setup.js b/prelude/sea-vfs-setup.js
index 94a34ea58..9f8d6296e 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];
@@ -213,14 +210,12 @@ var toManifestKey =
};
function _enoent(syscall, filePath) {
- var err = new Error(
+ return shared.makeFsError(
'ENOENT: no such file or directory, ' + syscall + " '" + filePath + "'",
+ 'ENOENT',
+ syscall,
+ filePath,
);
- err.code = 'ENOENT';
- err.errno = -2;
- err.syscall = syscall;
- err.path = filePath;
- return err;
}
// /////////////////////////////////////////////////////////////////
@@ -281,7 +276,9 @@ function _makeStats(meta) {
* at build time; the manifest's `offsets` map provides [byteOffset, byteLength]
* for each file so readFileSync can extract them via zero-copy Buffer.subarray().
*
- * Performance design:
+ * Performance design (each lookup first resolves symlinks, which is free
+ * when the manifest has none — see makeSymlinkResolver() in
+ * bootstrap-shared.js):
*
* - internalModuleStat() O(1) manifest hash lookup (no tree walk).
* This is the hottest path (~30K calls for large projects).
@@ -306,6 +303,7 @@ class SEAProvider extends MemoryProvider {
super();
this._manifest = seaManifest;
this._fileCache = new Map();
+ this._resolve = shared.makeSymlinkResolver(seaManifest.symlinks, '/');
// Pick the per-file decompressor once at construction time. Absent or 0 =
// uncompressed archive (backward compat with pre-#250 SEA binaries). The
@@ -337,26 +335,10 @@ class SEAProvider extends MemoryProvider {
}
_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;
+ // Count only lookups a symlink actually moved.
+ var resolved = this._resolve(p);
+ if (resolved !== p) perf.count('symlink resolutions');
+ return resolved;
}
get fileCacheSize() {
@@ -450,6 +432,16 @@ class SEAProvider extends MemoryProvider {
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));
+ if (p in this._manifest.stats) return p;
+ return super.realpathSync(p);
+ }
+
statSync(filePath) {
perf.count('statSync calls');
var p = this._resolveSymlink(toManifestKey(filePath));
@@ -472,7 +464,7 @@ class SEAProvider extends MemoryProvider {
if (meta) {
return meta.isDirectory ? 1 : 0;
}
- return -2;
+ return -shared.ERRNO.ENOENT;
}
readdirSync(dirPath) {
@@ -524,6 +516,12 @@ if (process.platform === 'win32') {
VirtualFileSystem.prototype.resolvePath = function (inputPath) {
return _origResolvePath.call(this, _winToVFS(inputPath));
};
+ // ...and convert back on the way out, so realpath agrees with __filename
+ // (`C:\snapshot\...`, not `/snapshot/...`) (#305).
+ var _origRealpathSync = VirtualFileSystem.prototype.realpathSync;
+ VirtualFileSystem.prototype.realpathSync = function (filePath, options) {
+ return toPlatformPath(_origRealpathSync.call(this, filePath, options));
+ };
}
virtualFs.mount(SNAPSHOT_PREFIX, { overlay: true });
diff --git a/test/test-99-#295/index.js b/test/test-99-#295/index.js
new file mode 100644
index 000000000..ff6196b16
--- /dev/null
+++ b/test/test-99-#295/index.js
@@ -0,0 +1,46 @@
+'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');
+
+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',
+ `realpath of the nested link (nestedIsLink=${nestedIsLink})`,
+);
+assert.strictEqual(
+ path.basename(fs.realpathSync(path.join(__dirname, 'lib', 'log.js'))),
+ 'log.js',
+ 'realpath through the linked directory',
+);
+
+// ...in the platform's path form, `C:\snapshot\...` on Windows (#305).
+assert.strictEqual(
+ fs.realpathSync(__filename),
+ __filename,
+ 'realpath must round-trip a non-link in the platform path form',
+);
+
+// readdir *of the linked directory* must list what the link points at.
+assert.ok(
+ fs.readdirSync(path.join(__dirname, 'lib')).includes('log.js'),
+ 'log.js missing from readdir of the linked directory',
+);
+
+log(42);
diff --git a/test/test-99-#295/main.js b/test/test-99-#295/main.js
new file mode 100644
index 000000000..b37a988ef
--- /dev/null
+++ b/test/test-99-#295/main.js
@@ -0,0 +1,76 @@
+#!/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());
+
+// Built at test time: git on Windows checks out a committed symlink as a text
+// file, and a junction is what npm creates for workspace links there.
+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();
+
+try {
+ fs.symlinkSync(
+ path.join(__dirname, 'reallib'),
+ libLink,
+ process.platform === 'win32' ? 'junction' : 'dir',
+ );
+
+ // A file symlink needs Developer Mode on Windows; index.js relaxes to match.
+ let nestedIsLink = true;
+ try {
+ fs.symlinkSync('log.js', innerLink, 'file');
+ } catch (error) {
+ if (process.platform !== 'win32') throw error;
+ console.log(` file symlink unavailable (${error.code}), using a copy`);
+ fs.copyFileSync(path.join(__dirname, 'reallib', 'log.js'), innerLink);
+ nestedIsLink = false;
+ }
+ fs.writeFileSync(linkInfo, `${JSON.stringify({ nestedIsLink }, null, 2)}\n`);
+
+ 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 000000000..33fd9e6eb
--- /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 000000000..2e92b2d93
--- /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 87e6dad8a..3541f2e78 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 000000000..eb8fa566a
--- /dev/null
+++ b/test/unit/resolve-symlink.test.ts
@@ -0,0 +1,287 @@
+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) => string;
+
+// makeSymlinkResolver() backs both the classic bootstrap and the SEA provider.
+// test-99-#295 covers one level of nesting end to end; these cover the rest.
+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('does not match a key that is only a string prefix of the path', () => {
+ // The scan slices at separator offsets, so /snapshot/foo must not swallow
+ // /snapshot/foobar. A naive startsWith() would pass every other case here.
+ const resolve = makeSymlinkResolver(
+ { '/snapshot/foo': '/snapshot/real' },
+ '/',
+ );
+ assert.equal(
+ resolve('/snapshot/foobar/x.js'),
+ '/snapshot/foobar/x.js',
+ 'sibling with a longer name must be left alone',
+ );
+ assert.equal(resolve('/snapshot/foo/x.js'), '/snapshot/real/x.js');
+ });
+
+ 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;
+ },
+ );
+ });
+
+ 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('allows 40 links and rejects 41, like SYMLOOP_MAX', () => {
+ // chain(n) is n + 1 links.
+ assert.equal(makeSymlinkResolver(chain(39), '/')('/l0'), '/end');
+ assert.throws(() => makeSymlinkResolver(chain(40), '/')('/l0'), {
+ code: 'ELOOP',
+ });
+ });
+
+ it('still resolves a short lookup after an over-long one threw', () => {
+ const resolve = makeSymlinkResolver(chain(41), '/');
+ assert.throws(() => resolve('/l0'), { code: 'ELOOP' });
+ assert.equal(resolve('/l30'), '/end');
+ });
+
+ 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');
+ });
+ });
+});