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