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