diff --git a/.gitignore b/.gitignore
index b57a3177f..7f1f62706 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,9 @@ 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
+test/test-99-#295/esm/node_modules
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index f78c1f491..f19819b63 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` (1964 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`
@@ -338,7 +338,7 @@ Execution flow (main thread):
The VFS polyfill (`@roberts_lando/vfs`) handles all `fs` and `fs/promises` patching automatically when `mount()` is called — intercepting 164+ functions including `readFile`, `readFileSync`, `stat`, `readdir`, `access`, `realpath`, `createReadStream`, `watch`, `open`, and their promise-based equivalents. It also hooks into the Node.js module resolution system for `require()` and `import`.
-**Windows path strategy:** The VFS always mounts at `/snapshot` (POSIX). On Windows, `sea-vfs-setup.js` patches `VirtualFileSystem.prototype.shouldHandle` and `resolvePath` to strip drive letters and convert `\` to `/` before the VFS processes them. The `insideSnapshot()` helper checks for `/snapshot`, `V:\snapshot` (sentinel drive used by `@roberts_lando/vfs` module hooks), and `C:\snapshot` (used by dlopen/child_process).
+**Windows path strategy:** The VFS always mounts at `/snapshot` (POSIX). On Windows, `sea-vfs-setup.js` patches `VirtualFileSystem.prototype.shouldHandle` and `resolvePath` to strip drive letters and convert `\` to `/` before the VFS processes them, and `VirtualFileSystem.prototype.realpathSync` to convert its result back to `C:\snapshot\...` so it matches `__filename` (#305). The `insideSnapshot()` helper checks for `/snapshot`, `V:\snapshot` (sentinel drive used by `@roberts_lando/vfs` module hooks), and `C:\snapshot` (used by dlopen/child_process).
### VFS Provider Architecture
@@ -398,10 +398,11 @@ The `SEAProvider` (in `prelude/sea-vfs-setup.js`) implements lazy loading from a
| -------------------------- | ----------------------------------------------------------------------------- |
| `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) |
+| `internalModuleStat(path)` | Fast path for module resolution: 0 (file), 1 (dir), or a negative errno |
| `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()` |
+| `readlinkSync(path)` | Unused by `fs.readlinkSync`, which vfs answers via `realpathSync` (#310) |
+| `realpathSync(path)` | Follow symlinks, return the path if the manifest has it |
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` (~751 lines) contains runtime patches used by both bootstraps:
### Injection Mechanisms
@@ -502,6 +503,8 @@ 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)`** — The symlink resolver for **both** modes (`findVirtualFileSystemKeyAndFollowLinks` in classic, `SEAProvider._resolveSymlink` in SEA). It follows parent components like POSIX, so a link at `node_modules/@scope/lib` also resolves `node_modules/@scope/lib/package.json` (#295). The longest matching key wins; that agrees with POSIX because the walker stores realpaths as targets (`toNormalizedRealPath`). More than `MAX_SYMLINK_DEPTH` hops, possible only in a hand-edited manifest, raises `ELOOP`.
+
**`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.
@@ -610,11 +613,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` | ~1964 | Traditional runtime bootstrap (fs/module/process patching) |
+| `prelude/bootstrap-shared.js` | ~751 | 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` | ~577 | 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..0069eca7b 100644
--- a/prelude/bootstrap-shared.js
+++ b/prelude/bootstrap-shared.js
@@ -632,6 +632,109 @@ 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,
+ };
+})();
+
+// Callers keep their own message: classic ENOENT carries pkg's asset hint.
+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;
+}
+
+// Maps a snapshot key through its symlinks, parent components included:
+// a link at `node_modules/@x/y` also resolves `.../@x/y/package.json` (#295).
+// Runs before every snapshot fs call. `display` is the caller's path, shown in
+// errors instead of the internal key.
+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;
+ }
+
+ function resolve(p, display, hops) {
+ // The walker stores realpaths as targets, so only a hand-edited manifest
+ // chains or loops; the hop bound turns a loop into ELOOP.
+ if (hops > MAX_SYMLINK_DEPTH) {
+ throw makeFsError(
+ "ELOOP: too many symbolic links encountered, '" + display + "'",
+ 'ELOOP',
+ undefined,
+ display,
+ );
+ }
+
+ if (typeof symlinks[p] === 'string') {
+ return resolve(symlinks[p], display, hops + 1);
+ }
+
+ // Longest prefix wins: `
/lib` and `/lib/sub` can both be keys.
+ 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 = symlinks[bestKey];
+ var rest = target.endsWith(sep) ? p.slice(bestPos + 1) : p.slice(bestPos);
+ return resolve(target + rest, display, hops + 1);
+ }
+
+ return function resolveKey(p, display) {
+ return resolve(p, display === undefined ? p : display, 0);
+ };
+}
+
module.exports = {
pkgCacheDir: pkgCacheDir,
patchDlopen: patchDlopen,
@@ -642,4 +745,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..0d5c39202 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), path_);
}
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_,
+ );
}
// ///////////////////////////////////////////////////////////////
@@ -1107,7 +1096,7 @@ function payloadFileSync(pointer) {
Dirent.prototype.isFIFO = noop;
Dirent.prototype.isSymbolicLink = (fileOrFolderName) =>
- Boolean(SYMLINKS[fileOrFolderName]);
+ typeof SYMLINKS[fileOrFolderName] === 'string';
function getFileTypes(path_, entries) {
return entries.map((entry) => {
@@ -1442,7 +1431,13 @@ function payloadFileSync(pointer) {
}
function existsFromSnapshot(path_) {
- const entity = findVirtualFileSystemEntry(path_);
+ let entity;
+ try {
+ entity = findVirtualFileSystemEntry(path_);
+ } catch {
+ // Like fs.existsSync: a symlink loop is just "not there".
+ return false;
+ }
if (!entity) return findNativeAddonForExists(path_);
return true;
}
@@ -1652,7 +1647,13 @@ function payloadFileSync(pointer) {
.internalModuleStat(makeLong(translate(path_)));
}
- const entity = findVirtualFileSystemEntry(path_);
+ let entity;
+ try {
+ entity = findVirtualFileSystemEntry(path_);
+ } catch (error) {
+ if (error.code !== 'ELOOP') throw error;
+ return -REQUIRE_SHARED.ERRNO.ELOOP;
+ }
if (!entity) {
return findNativeAddonForInternalModuleStat(path_);
diff --git a/prelude/sea-vfs-setup.js b/prelude/sea-vfs-setup.js
index c32cd8472..7283c7975 100644
--- a/prelude/sea-vfs-setup.js
+++ b/prelude/sea-vfs-setup.js
@@ -21,10 +21,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 /////////////////////////////////////
// /////////////////////////////////////////////////////////////////
@@ -148,6 +144,7 @@ var perf = {
'statSync calls',
'existsSync calls',
'readdirSync calls',
+ 'symlink resolutions',
];
counterOrder.forEach(function (label) {
var v = self._counters[label];
@@ -210,14 +207,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;
}
// /////////////////////////////////////////////////////////////////
@@ -278,7 +273,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).
@@ -303,6 +300,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
@@ -333,27 +331,12 @@ 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(filePath) {
+ var p = toManifestKey(filePath);
+ var resolved = this._resolve(p, filePath);
+ // Count only lookups a symlink actually moved.
+ if (resolved !== p) perf.count('symlink resolutions');
+ return resolved;
}
get fileCacheSize() {
@@ -361,7 +344,7 @@ class SEAProvider extends MemoryProvider {
}
readFileSync(filePath, options) {
- var p = this._resolveSymlink(toManifestKey(filePath));
+ var p = this._resolveSymlink(filePath);
// Fast path: for compressed archives, a per-file decompressed Buffer is
// memoised in _fileCache (decompression is expensive and most prelude
// modules are read once during module resolution, twice for the compile
@@ -443,13 +426,20 @@ class SEAProvider extends MemoryProvider {
readlinkSync(filePath) {
var p = toManifestKey(filePath);
var target = this._manifest.symlinks[p];
- if (target) return target;
+ if (typeof target === 'string') return target;
return super.readlinkSync(p);
}
+ realpathSync(filePath) {
+ // The base class only knows directories, not archive files.
+ var p = this._resolveSymlink(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));
+ var p = this._resolveSymlink(filePath);
var meta = this._manifest.stats[p];
if (meta) {
// Return a fresh stat object — matches Node.js fs.statSync contract.
@@ -464,17 +454,17 @@ class SEAProvider extends MemoryProvider {
* startup (~30K calls for large projects) so it must be as lean as possible.
*/
internalModuleStat(filePath) {
- var p = this._resolveSymlink(toManifestKey(filePath));
+ var p = this._resolveSymlink(filePath);
var meta = this._manifest.stats[p];
if (meta) {
return meta.isDirectory ? 1 : 0;
}
- return -2;
+ return -shared.ERRNO.ENOENT;
}
readdirSync(dirPath) {
perf.count('readdirSync calls');
- var p = this._resolveSymlink(toManifestKey(dirPath));
+ var p = this._resolveSymlink(dirPath);
var entries = this._manifest.directories[p];
if (entries) return entries.slice();
return super.readdirSync(p);
@@ -482,7 +472,7 @@ class SEAProvider extends MemoryProvider {
existsSync(filePath) {
perf.count('existsSync calls');
- var p = this._resolveSymlink(toManifestKey(filePath));
+ var p = this._resolveSymlink(filePath);
return p in this._manifest.stats;
}
}
@@ -521,6 +511,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-50-corrupt-executable/main.js b/test/test-50-corrupt-executable/main.js
index 0fbb28305..c1d0c8b97 100644
--- a/test/test-50-corrupt-executable/main.js
+++ b/test/test-50-corrupt-executable/main.js
@@ -35,6 +35,9 @@ damage[damage.length - 4 * boundary - 10] = 0x2;
damage[damage.length - 2 * boundary + 10] = 0x2;
damage[damage.length - 3 * boundary + 10] = 0x2;
damage[damage.length - 4 * boundary + 10] = 0x2;
+// The prelude sits last, so the offsets above can all land in comments (CRLF
+// checkouts on Windows); its closing `})` is always code.
+damage[damage.length - 2] = 0x2;
fs.writeFileSync(output, damage);
if (process.platform === 'darwin') {
diff --git a/test/test-99-#295/esm/index.mjs b/test/test-99-#295/esm/index.mjs
new file mode 100644
index 000000000..ae1c008c4
--- /dev/null
+++ b/test/test-99-#295/esm/index.mjs
@@ -0,0 +1,5 @@
+// The #295 report: an ESM import of a workspace package linked into
+// node_modules.
+import log from '@t/lib';
+
+log(42);
diff --git a/test/test-99-#295/esm/package.json b/test/test-99-#295/esm/package.json
new file mode 100644
index 000000000..612e3d835
--- /dev/null
+++ b/test/test-99-#295/esm/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "test-99-295-esm",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "index.mjs",
+ "bin": "index.mjs"
+}
diff --git a/test/test-99-#295/index.js b/test/test-99-#295/index.js
new file mode 100644
index 000000000..2605c20f3
--- /dev/null
+++ b/test/test-99-#295/index.js
@@ -0,0 +1,62 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { isSea } = require('node:sea');
+
+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). Only
+// Windows can tell the two forms apart; elsewhere this passes trivially.
+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.deepStrictEqual(
+ fs.readdirSync(path.join(__dirname, 'lib')).sort(),
+ ['inner.js', 'log.js', 'package.json'],
+ 'readdir of the linked directory',
+);
+
+// A miss through the linked directory is a proper ENOENT. SEA errors come
+// from vfs; standard mode builds its own.
+assert.throws(
+ () => fs.statSync(path.join(__dirname, 'lib', 'missing.js')),
+ (error) => {
+ assert.strictEqual(error.code, 'ENOENT');
+ assert.strictEqual(path.basename(error.path), 'missing.js');
+ if (isSea()) assert.strictEqual(error.syscall, 'stat');
+ else assert.strictEqual(error.pkg, true);
+ return true;
+ },
+);
+
+log(42);
diff --git a/test/test-99-#295/main.js b/test/test-99-#295/main.js
new file mode 100644
index 000000000..aaf6e8620
--- /dev/null
+++ b/test/test-99-#295/main.js
@@ -0,0 +1,87 @@
+#!/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 esmModules = path.join(__dirname, 'esm', 'node_modules');
+const generated = [libLink, innerLink, linkInfo, esmModules];
+
+function removeGenerated() {
+ for (const p of generated) utils.vacuum.sync(p);
+}
+
+removeGenerated();
+
+const dirLinkType = process.platform === 'win32' ? 'junction' : 'dir';
+
+try {
+ fs.symlinkSync(path.join(__dirname, 'reallib'), libLink, dirLinkType);
+
+ // A workspace package, linked the way npm links one into node_modules.
+ fs.mkdirSync(path.join(esmModules, '@t'), { recursive: true });
+ fs.symlinkSync(
+ path.join(__dirname, 'reallib'),
+ path.join(esmModules, '@t', 'lib'),
+ dirLinkType,
+ );
+
+ // 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 expectedOutput = '42\n';
+ // CJS entry with nested links, and the ESM workspace import from #295.
+ const cases = [
+ { input: './package.json', name: 'test-99-#295' },
+ { input: './esm/package.json', name: 'test-99-#295-esm' },
+ ];
+
+ const newcomers = cases.flatMap(({ name }) =>
+ utils.seaHostOutputs(name).concat(`${name}-std.exe`),
+ );
+
+ const before = utils.filesBefore(newcomers);
+
+ for (const { input, name } of cases) {
+ // SEA mode, the one #295 was reported against.
+ utils.runSeaHostOnly(input, name);
+ utils.assertSeaOutput(name, expectedOutput);
+
+ // Standard mode shares the resolver, so it gets the same fixture.
+ const standardOutput = `${name}-std.exe`;
+ utils.pkg.sync(['--target', 'host', '--output', standardOutput, input]);
+ assert.strictEqual(
+ utils.spawn.sync(`./${standardOutput}`, []).replace(/\r\n/g, '\n'),
+ expectedOutput,
+ `standard mode output for ${input}`,
+ );
+ }
+
+ 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-99-#295/reallib/package.json b/test/test-99-#295/reallib/package.json
new file mode 100644
index 000000000..a7d0535e5
--- /dev/null
+++ b/test/test-99-#295/reallib/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "@t/lib",
+ "version": "1.0.0",
+ "main": "log.js"
+}
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..5ec112229
--- /dev/null
+++ b/test/unit/resolve-symlink.test.ts
@@ -0,0 +1,210 @@
+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, _display?: 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', () => {
+ // 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)', () => {
+ // Targets are realpaths, so the deeper key is already the full answer.
+ 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 walker keys `/sub` on the path it walked, through ``.
+ 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', () => {
+ // Same shape as test-99-#295/lib/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', () => {
+ 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('reports the caller path, not the internal key, on ELOOP', () => {
+ const resolve = makeSymlinkResolver({ '/a': '/a/b' }, '/');
+ assert.throws(() => resolve('/a/x', 'C:\\snapshot\\a\\x'), {
+ code: 'ELOOP',
+ path: 'C:\\snapshot\\a\\x',
+ });
+ });
+
+ 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('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',
+ );
+ });
+
+ it('walks win32 keys that carry a drive letter', () => {
+ const resolve = makeSymlinkResolver(
+ {
+ 'C:\\snapshot\\lib': 'C:\\snapshot\\reallib',
+ 'C:\\snapshot\\lib\\sub': 'C:\\snapshot\\reallib\\realsub',
+ },
+ '\\',
+ );
+ assert.equal(
+ resolve('C:\\snapshot\\lib\\sub\\file.js'),
+ 'C:\\snapshot\\reallib\\realsub\\file.js',
+ );
+ assert.equal(
+ resolve('C:\\snapshot\\lib\\other.js'),
+ 'C:\\snapshot\\reallib\\other.js',
+ );
+ });
+
+ describe('empty manifest', () => {
+ it('returns every path unchanged', () => {
+ const resolve = makeSymlinkResolver({}, '/');
+ assert.equal(resolve('/snapshot/app/index.js'), '/snapshot/app/index.js');
+ });
+ });
+});