diff --git a/.gitignore b/.gitignore index 7f1f62706..0698e91f7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,6 @@ 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/reallib/other.js test/test-99-#295/linkinfo.json test/test-99-#295/esm/node_modules diff --git a/docs-site/guide/snapshot-fs.md b/docs-site/guide/snapshot-fs.md index bbeb14eda..2466e720b 100644 --- a/docs-site/guide/snapshot-fs.md +++ b/docs-site/guide/snapshot-fs.md @@ -49,6 +49,24 @@ const configPath = path.join(path.dirname(process.execPath), 'config.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); ``` +## Symlinks + +Symlinks collected into the snapshot (pnpm and workspace `node_modules`, `node_modules/.bin`, links among your assets) behave as they do outside the binary: + +```js +fs.lstatSync(link).isSymbolicLink(); // true +fs.readdirSync(dir, { withFileTypes: true }); // links report isSymbolicLink() +fs.readlinkSync(link); // '/snapshot/app/node_modules/.pnpm/x/node_modules/x' +``` + +`readlink` returns the absolute target inside the snapshot, not the relative link body on disk. + +::: warning Changed in 7.0.0 +Before, links looked like the file or directory they point at. Code that finds entries with `d.isDirectory()` or `d.isFile()` now skips linked ones, the same as plain Node. Check `d.isSymbolicLink()` and `fs.statSync()` the entry, or pass `follow: true` to `glob`. fast-glob and readdirp follow links by default. +::: + +See [symlink semantics](https://github.com/yao-pkg/pkg/blob/main/docs/ARCHITECTURE.md#symlink-semantics-in-packaged-binaries) for the details. + ## Detecting that you're packaged Check for `process.pkg`: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f19819b63..ce6ef8554 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` (1964 lines) executes before user code. It: +`prelude/bootstrap.js` (~2049 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,15 +394,16 @@ 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: 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)` | Unused by `fs.readlinkSync`, which vfs answers via `realpathSync` (#310) | -| `realpathSync(path)` | Follow symlinks, return the path if the manifest has it | +| 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 manifest directory entries directly; with `withFileTypes` builds shared `Dirent`s, marking links from `manifest.symlinks` | +| `existsSync(path)` | O(1) check against manifest `stats` | +| `readlinkSync(path)` | Return the symlink target from `manifest.symlinks`, resolving a symlinked parent first; `EINVAL` for a path that is present but not a link, `ENOENT` otherwise. Reached through `fs.readlinkSync` by the VFS's own patch (robertsLando/vfs#4) | +| `lstatSync(path)` | Describe the link itself: the target's stat with link semantics and `S_IFLNK` type bits. Reached through `fs.lstatSync` the same way as `readlinkSync` above | +| `realpathSync(path)` | Follow the symlink chain, then return the path if the manifest has it. Load-bearing: without it every archive path raises `ENOENT`. 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. @@ -468,7 +469,7 @@ This keeps the VFS setup, shared patches, worker interception, and diagnostics a ## Shared Runtime Code -`prelude/bootstrap-shared.js` (~751 lines) contains runtime patches used by both bootstraps: +`prelude/bootstrap-shared.js` (~859 lines) contains runtime patches used by both bootstraps: ### Injection Mechanisms @@ -505,6 +506,27 @@ This keeps the VFS setup, shared patches, worker interception, and diagnostics a **`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`. +### Symlink semantics in packaged binaries + +Both bootstraps resolve symlinks on parent path components, so `require`, `fs.readFile` and friends reach files under a linked directory. The walker keys a link that sits inside a linked directory under the real directory (`reallib/inner.js`, not `lib/inner.js`) and records the directory link too, so both spellings resolve. Where they differ: + +| | Traditional | Enhanced SEA | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `readdir({ withFileTypes: true })` | Reports a snapshot symlink as a link (`isSymbolicLink()` true, `isDirectory()`/`isFile()` false), matching real `readdir` | Same — the provider builds `Dirent`s from `manifest.symlinks`, using the shared `Dirent` in `bootstrap-shared.js` | +| `lstat` | Describes the link itself, agreeing with the dirent above | Same — `SEAProvider.lstatSync` gives the target's stat link semantics | +| `readlink` | Returns the absolute target from `SYMLINKS`; `EINVAL` for a path that exists but is not a link | Same, from `manifest.symlinks`, including the shared parent-resolution step | +| `realpath` | Follows the chain | Follows the chain | + +> **Breaking change (both modes).** Snapshot symlinks used to look like what they point at. In traditional mode `readdir({ withFileTypes: true })` reported every entry as a plain file or directory (`Dirent.isSymbolicLink()` took an argument it is never called with, so it always returned `false`); in SEA mode it returned entries without the `Dirent` methods. `lstat` returned the target's stat in both. Both now report links as links, which is what Node does outside a packaged binary. For packaged apps whose snapshot contains symlinks (pnpm and workspace trees most of all, plus `node_modules/.bin`): +> +> - Recursive walkers that gate descent on `isDirectory()` and skip links by default (`glob` without `follow: true`, hand-written `isDirectory()` recursions) no longer descend into a symlinked directory. fast-glob and readdirp follow links by default and are unaffected. +> - A filter like `entries.filter((e) => e.isFile())` no longer matches a symlinked **file**. +> - `lstatSync(link).isDirectory()` / `.isFile()` are now `false`, `mode & S_IFMT` is `S_IFLNK`, and `size` is the byte length of the target path. +> +> `readlink` returns the walker's **absolute, fully resolved** target (`/snapshot/app/node_modules/.pnpm/...`, in the platform path form), not the raw link body on disk. Tools that re-create links from it (tar, archiver, a hand-written copy) write absolute `/snapshot/...` links. (`fs.cp` cannot copy out of the snapshot in either mode.) + +> **How SEA reports links.** `@roberts_lando/vfs` used to route `fs.lstat` through `statSync`, which follows the link, and `fs.readlink` through `realpathSync`, which never reached the provider. [robertsLando/vfs#4](https://github.com/robertsLando/vfs/pull/4) routes both to `SEAProvider`, which answers from `manifest.symlinks`, the same record the resolver walks. Released in `@roberts_lando/vfs` 0.4.0. + **`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. @@ -613,11 +635,11 @@ With `node:vfs` and `"useVfs": true` in the SEA config, assets will be auto-moun | File | Lines | Purpose | | -------------------------------- | ----- | -------------------------------------------------------------------------------------------- | -| `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/bootstrap.js` | ~2049 | Traditional runtime bootstrap (fs/module/process patching) | +| `prelude/bootstrap-shared.js` | ~859 | 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` | ~577 | SEA VFS core: SEAProvider, archive loading, VFS mount, Windows patches | +| `prelude/sea-vfs-setup.js` | ~648 | 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/lib/walker.ts b/lib/walker.ts index deb8e870f..4f9949b91 100644 --- a/lib/walker.ts +++ b/lib/walker.ts @@ -447,6 +447,15 @@ class Walker { file = a.file; realFile = a.realFile; + // A link inside a linked directory lives in the real one: key it there so + // its real path resolves too, and record the directory link it came through. + const dir = path.dirname(file); + const realDir = toNormalizedRealPath(dir); + if (realDir !== dir) { + await this.appendSymlink(dir, realDir); + file = path.join(realDir, path.basename(file)); + } + if (!this.symLinks[file]) { const dn = path.dirname(file); this.appendFileInFolder({ diff --git a/package.json b/package.json index 36add1aa9..e6055be0c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@babel/parser": "^7.23.0", "@babel/traverse": "^7.23.0", "@babel/types": "^7.23.0", - "@roberts_lando/vfs": "^0.3.3", + "@roberts_lando/vfs": "^0.4.0", "@yao-pkg/pkg-fetch": "3.6.7", "esbuild": "^0.28.1", "into-stream": "^9.1.0", diff --git a/prelude/bootstrap-shared.js b/prelude/bootstrap-shared.js index 0069eca7b..866e36656 100644 --- a/prelude/bootstrap-shared.js +++ b/prelude/bootstrap-shared.js @@ -606,6 +606,8 @@ function installDiagnostic(snapshotPrefix) { wrap(fs, 'readdir'); wrap(fs, 'realpathSync'); wrap(fs, 'realpath'); + wrap(fs, 'readlinkSync'); + wrap(fs, 'readlink'); wrap(fs, 'statSync'); wrap(fs, 'stat'); wrap(fs, 'lstatSync'); @@ -624,6 +626,7 @@ function installDiagnostic(snapshotPrefix) { wrap(fs.promises, 'write'); wrap(fs.promises, 'readdir'); wrap(fs.promises, 'realpath'); + wrap(fs.promises, 'readlink'); wrap(fs.promises, 'stat'); wrap(fs.promises, 'lstat'); wrap(fs.promises, 'access'); @@ -646,6 +649,7 @@ var ERRNO = (function () { ENOTDIR: windows ? 4052 : 20, ENOENT: windows ? 4058 : 2, EISDIR: windows ? 4068 : 21, + EINVAL: windows ? 4071 : 22, ELOOP: windows ? 4067 : 40, }; })(); @@ -661,6 +665,90 @@ function makeFsError(message, code, syscall, path_) { return err; } +// libuv dirent types, as readdir({ withFileTypes: true }) reports them. +var UV_DIRENT_FILE = 1; +var UV_DIRENT_DIR = 2; +var UV_DIRENT_LINK = 3; + +// POSIX file-type bits, so `mode & S_IFMT` agrees with isSymbolicLink(). +var S_IFMT = 0o170000; +var S_IFLNK = 0o120000; + +function alwaysFalse() { + return false; +} + +// Node 24 removed the deprecated `path` alias (DEP0178). +var DIRENT_HAS_PATH = Number(process.versions.node.split('.')[0]) < 24; + +/** + * The Dirent both bootstraps return from readdir({ withFileTypes: true }). + * The type is fixed at construction: fs.Dirent's predicates take no argument. + */ +function Dirent(name, type, parentPath) { + this.name = name; + this.type = type; + this.parentPath = parentPath; + if (DIRENT_HAS_PATH) this.path = parentPath; +} + +Dirent.prototype.isDirectory = function isDirectory() { + return this.type === UV_DIRENT_DIR; +}; + +Dirent.prototype.isFile = function isFile() { + return this.type === UV_DIRENT_FILE; +}; + +Dirent.prototype.isSymbolicLink = function isSymbolicLink() { + return this.type === UV_DIRENT_LINK; +}; + +Dirent.prototype.isBlockDevice = alwaysFalse; +Dirent.prototype.isCharacterDevice = alwaysFalse; +Dirent.prototype.isSocket = alwaysFalse; +Dirent.prototype.isFIFO = alwaysFalse; + +/** + * Where a snapshot path links to, or undefined when it is not a link. + * The walker keys every link under its parent's real path, so callers pass + * the path with its parents resolved. + */ +function linkTarget(symlinks, resolvedKey) { + // typeof: the record is read with a bracket index, so inherited keys like + // `constructor` must not match. + if (typeof symlinks[resolvedKey] === 'string') return symlinks[resolvedKey]; + return undefined; +} + +/** + * Give a through-the-link stat link semantics, as POSIX lstat reports them: + * link type bits, size of the target string, no blocks. + */ +function asSymlinkStat(s, target) { + s.isSymbolicLink = function () { + return true; + }; + s.isFile = alwaysFalse; + s.isDirectory = alwaysFalse; + s.mode = (s.mode & ~S_IFMT) | S_IFLNK; + s.size = Buffer.byteLength(target); + s.blocks = 0; + s.nlink = 1; + return s; +} + +// readlink's options are an encoding string or { encoding }. +function applyReadlinkEncoding(target, options) { + var encoding = + typeof options === 'string' ? options : options && options.encoding; + if (encoding === 'buffer') return Buffer.from(target); + if (encoding && encoding !== 'utf8' && encoding !== 'utf-8') { + return Buffer.from(target).toString(encoding); + } + return target; +} + // 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 @@ -668,11 +756,14 @@ function makeFsError(message, code, syscall, path_) { function makeSymlinkResolver(symlinks, sep) { var keys = Object.keys(symlinks); - // A symlink-free binary pays nothing. + // A symlink-free binary pays nothing. Still carries .parent for readlink + // and lstat. if (keys.length === 0) { - return function (p) { + var identity = function (p) { return p; }; + identity.parent = identity; + return identity; } // Links sit at a few depths, so the scan below only slices at those. @@ -730,9 +821,19 @@ function makeSymlinkResolver(symlinks, sep) { return resolve(target + rest, display, hops + 1); } - return function resolveKey(p, display) { + function resolveKey(p, display) { return resolve(p, display === undefined ? p : display, 0); + } + + // The key once a path's parents are followed but its last component is not, + // which is what POSIX resolves before reading a link. + resolveKey.parent = function (p, display) { + var slash = p.lastIndexOf(sep); + if (slash <= 0) return p; + return resolveKey(p.slice(0, slash), display || p) + p.slice(slash); }; + + return resolveKey; } module.exports = { @@ -748,4 +849,11 @@ module.exports = { makeSymlinkResolver: makeSymlinkResolver, ERRNO: ERRNO, makeFsError: makeFsError, + Dirent: Dirent, + linkTarget: linkTarget, + asSymlinkStat: asSymlinkStat, + applyReadlinkEncoding: applyReadlinkEncoding, + UV_DIRENT_FILE: UV_DIRENT_FILE, + UV_DIRENT_DIR: UV_DIRENT_DIR, + UV_DIRENT_LINK: UV_DIRENT_LINK, }; diff --git a/prelude/bootstrap.js b/prelude/bootstrap.js index 0d5c39202..8204df5f9 100644 --- a/prelude/bootstrap.js +++ b/prelude/bootstrap.js @@ -486,6 +486,8 @@ function payloadFileSync(pointer) { readdir: fs.readdir, realpathSync: fs.realpathSync, realpath: fs.realpath, + readlinkSync: fs.readlinkSync, + readlink: fs.readlink, statSync: fs.statSync, stat: fs.stat, lstatSync: fs.lstatSync, @@ -547,6 +549,15 @@ function payloadFileSync(pointer) { ); } + function error_EINVAL(syscall, path_) { + return makeFsError( + `EINVAL: invalid argument, ${syscall} '${stripSnapshot(path_)}'`, + 'EINVAL', + syscall, + path_, + ); + } + function error_ENOTDIR(path_) { return makeFsError( `ENOTDIR: not a directory, scandir '${path_}'`, @@ -1076,36 +1087,38 @@ function payloadFileSync(pointer) { return null; } - function Dirent(name, type) { - this.name = name; - this.type = type; - } - - Dirent.prototype.isDirectory = function isDirectory() { - return this.type === 2; - }; - - Dirent.prototype.isFile = function isFile() { - return this.type === 1; - }; - + // Shared with the SEA provider so both modes report the same dirent shape. + const Dirent = REQUIRE_SHARED.Dirent; + const UV_DIRENT_FILE = REQUIRE_SHARED.UV_DIRENT_FILE; + const UV_DIRENT_DIR = REQUIRE_SHARED.UV_DIRENT_DIR; + const UV_DIRENT_LINK = REQUIRE_SHARED.UV_DIRENT_LINK; const noop = () => false; - Dirent.prototype.isBlockDevice = noop; - Dirent.prototype.isCharacterDevice = noop; - Dirent.prototype.isSocket = noop; - Dirent.prototype.isFIFO = noop; - Dirent.prototype.isSymbolicLink = (fileOrFolderName) => - typeof SYMLINKS[fileOrFolderName] === 'string'; + // Where a snapshot path links to — the same decision the SEA provider makes. + function snapshotLinkTarget(vfsKey, display) { + return REQUIRE_SHARED.linkTarget( + SYMLINKS, + resolveSymlink.parent(vfsKey, display), + ); + } function getFileTypes(path_, entries) { + // parentPath is the caller's path, as Node reports it. + const parentPath = path_; return entries.map((entry) => { const ff = path.join(path_, entry); - const entity = findVirtualFileSystemEntry(ff); + // Asked before the entity lookup, which follows links: a link whose + // target is missing from the snapshot is still a link. + const vfsKey = findVirtualFileSystemKey(ff, path.sep); + if (snapshotLinkTarget(vfsKey, ff) !== undefined) + return new Dirent(entry, UV_DIRENT_LINK, parentPath); + // Reuses the key above; under DOCOMPRESS building it is not cheap. + const entity = VIRTUAL_FILESYSTEM[resolveSymlink(vfsKey, ff)]; if (!entity) return undefined; if (entity[STORE_BLOB] || entity[STORE_CONTENT]) - return new Dirent(entry, 1); - if (entity[STORE_LINKS]) return new Dirent(entry, 2); + return new Dirent(entry, UV_DIRENT_FILE, parentPath); + if (entity[STORE_LINKS]) + return new Dirent(entry, UV_DIRENT_DIR, parentPath); throw new Error('UNEXPECTED-24'); }); } @@ -1113,7 +1126,7 @@ function payloadFileSync(pointer) { function readdirRoot(path_, options, cb) { function addSnapshot(entries) { if (options && options.withFileTypes) { - entries.push(new Dirent('snapshot', 2)); + entries.push(new Dirent('snapshot', UV_DIRENT_DIR, path_)); } else { entries.push('snapshot'); } @@ -1256,6 +1269,53 @@ function payloadFileSync(pointer) { fs.realpathSync.native = fs.realpathSync; fs.realpath.native = fs.realpath; + // /////////////////////////////////////////////////////////////// + // readlink ////////////////////////////////////////////////////// + // /////////////////////////////////////////////////////////////// + + // Unpatched, readlink on a /snapshot path reaches the host fs and ENOENTs. + const applyReadlinkEncoding = REQUIRE_SHARED.applyReadlinkEncoding; + + function readlinkFromSnapshot(path_) { + const vfsKey = findVirtualFileSystemKey(path_, path.sep); + const target = snapshotLinkTarget(vfsKey, path_); + if (target !== undefined) return toOriginal(target); + // EINVAL for a path that exists but is not a link, as Node answers. + if (typeof VIRTUAL_FILESYSTEM[resolveSymlink(vfsKey, path_)] === 'object') { + throw error_EINVAL('readlink', path_); + } + throw error_ENOENT('File or directory', path_); + } + + fs.readlinkSync = function readlinkSync(path_, options) { + if (!insideSnapshot(path_)) { + return ancestor.readlinkSync.apply(fs, arguments); + } + if (insideMountpoint(path_)) { + return ancestor.readlinkSync.apply(fs, translateNth(arguments, 0, path_)); + } + + return applyReadlinkEncoding(readlinkFromSnapshot(path_), options); + }; + + fs.readlink = function readlink(path_, options) { + if (!insideSnapshot(path_)) { + return ancestor.readlink.apply(fs, arguments); + } + if (insideMountpoint(path_)) { + return ancestor.readlink.apply(fs, translateNth(arguments, 0, path_)); + } + + const callback = dezalgo(maybeCallback(arguments)); + let target; + try { + target = readlinkFromSnapshot(path_); + } catch (error) { + return callback(error); + } + callback(null, applyReadlinkEncoding(target, options)); + }; + // /////////////////////////////////////////////////////////////// // stat ////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////// @@ -1368,6 +1428,30 @@ function payloadFileSync(pointer) { // lstat ///////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////// + // The walker stats through links, so a link's stored stat describes its + // target; SYMLINKS is what says it is a link at all. + const asSymlinkStat = REQUIRE_SHARED.asSymlinkStat; + + function lstatFromSnapshot(path_, cb) { + const vfsKey = findVirtualFileSystemKey(path_, path.sep); + const target = snapshotLinkTarget(vfsKey, path_); + if (target === undefined) return statFromSnapshot(path_, cb); + // The target's stat, given link semantics — what the SEA provider does. + const entity = VIRTUAL_FILESYSTEM[resolveSymlink(vfsKey, path_)]; + const entityStat = entity && entity[STORE_STAT]; + if (!entityStat) { + return (cb || rethrow)(error_ENOENT('File or directory', path_)); + } + const shown = toOriginal(target); + if (cb) { + return statFromSnapshotSub(entityStat, (error, s) => { + if (error) return cb(error); + cb(null, asSymlinkStat(s, shown)); + }); + } + return asSymlinkStat(statFromSnapshotSub(entityStat), shown); + } + fs.lstatSync = function lstatSync(path_) { if (!insideSnapshot(path_)) { return ancestor.lstatSync.apply(fs, arguments); @@ -1376,7 +1460,7 @@ function payloadFileSync(pointer) { return ancestor.lstatSync.apply(fs, translateNth(arguments, 0, path_)); } - return statFromSnapshot(path_); + return lstatFromSnapshot(path_); }; fs.lstat = function lstat(path_) { @@ -1388,7 +1472,7 @@ function payloadFileSync(pointer) { } const callback = dezalgo(maybeCallback(arguments)); - statFromSnapshot(path_, callback); + lstatFromSnapshot(path_, callback); }; // /////////////////////////////////////////////////////////////// @@ -1546,6 +1630,7 @@ function payloadFileSync(pointer) { realpath: fs.promises.realpath, stat: fs.promises.stat, lstat: fs.promises.lstat, + readlink: fs.promises.readlink, fstat: fs.promises.fstat, access: fs.promises.access, copyFile: fs.promises.copyFile, @@ -1601,13 +1686,13 @@ function payloadFileSync(pointer) { fs.promises.read = util.promisify(fs.read); fs.promises.realpath = util.promisify(fs.realpath); + fs.promises.readlink = util.promisify(fs.readlink); fs.promises.fstat = util.promisify(fs.fstat); fs.promises.statfs = util.promisify(fs.statfs); fs.promises.access = util.promisify(fs.access); // TODO: all promises methods that try to edit files in snapshot should throw // TODO implement missing methods - // fs.promises.readlink ? // fs.promises.opendir ? } diff --git a/prelude/sea-vfs-setup.js b/prelude/sea-vfs-setup.js index 7283c7975..e05f33aba 100644 --- a/prelude/sea-vfs-setup.js +++ b/prelude/sea-vfs-setup.js @@ -206,12 +206,30 @@ var toManifestKey = return _stripTrailingSeps(p); }; +// The VFS strips the mount prefix before calling the provider, so an error +// built from what arrives here names a path the caller never used. Put the +// prefix and the platform form back, so err.path matches classic mode. +function toCallerPath(providerPath) { + return toPlatformPath(SNAPSHOT_PREFIX + providerPath); +} + +function _einval(syscall, filePath) { + var shown = toCallerPath(filePath); + return shared.makeFsError( + 'EINVAL: invalid argument, ' + syscall + " '" + shown + "'", + 'EINVAL', + syscall, + shown, + ); +} + function _enoent(syscall, filePath) { + var shown = toCallerPath(filePath); return shared.makeFsError( - 'ENOENT: no such file or directory, ' + syscall + " '" + filePath + "'", + 'ENOENT: no such file or directory, ' + syscall + " '" + shown + "'", 'ENOENT', syscall, - filePath, + shown, ); } @@ -300,7 +318,8 @@ class SEAProvider extends MemoryProvider { super(); this._manifest = seaManifest; this._fileCache = new Map(); - this._resolve = shared.makeSymlinkResolver(seaManifest.symlinks, '/'); + this._symlinks = seaManifest.symlinks; + this._resolve = shared.makeSymlinkResolver(this._symlinks, '/'); // Pick the per-file decompressor once at construction time. Absent or 0 = // uncompressed archive (backward compat with pre-#250 SEA binaries). The @@ -423,11 +442,21 @@ class SEAProvider extends MemoryProvider { return copy; } + // A plain string: the VFS maps it into the mount, then the + // VirtualFileSystem patch below applies the caller's encoding. readlinkSync(filePath) { - var p = toManifestKey(filePath); - var target = this._manifest.symlinks[p]; - if (typeof target === 'string') return target; - return super.readlinkSync(p); + var key = toManifestKey(filePath); + var resolvedKey = this._resolve.parent(key, filePath); + var target = shared.linkTarget(this._symlinks, resolvedKey); + if (target !== undefined) return target; + // EINVAL for a path that exists but is not a link, as classic mode does. + if ( + resolvedKey in this._manifest.stats || + resolvedKey in this._manifest.directories + ) { + throw _einval('readlink', filePath); + } + throw _enoent('readlink', filePath); } realpathSync(filePath) { @@ -462,12 +491,51 @@ class SEAProvider extends MemoryProvider { return -shared.ERRNO.ENOENT; } - readdirSync(dirPath) { + readdirSync(dirPath, options) { perf.count('readdirSync calls'); + var key = toManifestKey(dirPath); var p = this._resolveSymlink(dirPath); var entries = this._manifest.directories[p]; - if (entries) return entries.slice(); - return super.readdirSync(p); + if (!entries) return super.readdirSync(p, options); + if (!options || !options.withFileTypes) return entries.slice(); + var resolvedBase = p.endsWith('/') ? p : p + '/'; + var parentPath = toCallerPath(key); + var self = this; + return entries.map(function (name) { + var type = self._direntType(resolvedBase + name); + return new shared.Dirent(name, type, parentPath); + }); + } + + // A link when the manifest keys it as one; what it points at is not asked. + // An entry with no manifest record of its own reports as a file. + _direntType(resolvedKey) { + if (shared.linkTarget(this._symlinks, resolvedKey) !== undefined) { + return shared.UV_DIRENT_LINK; + } + if (Array.isArray(this._manifest.directories[resolvedKey])) { + return shared.UV_DIRENT_DIR; + } + var meta = this._manifest.stats[resolvedKey]; + if (typeof meta === 'object' && meta.isDirectory) { + return shared.UV_DIRENT_DIR; + } + return shared.UV_DIRENT_FILE; + } + + // The target's stat given link semantics — what classic mode returns. + lstatSync(filePath) { + var key = toManifestKey(filePath); + var target = shared.linkTarget( + this._symlinks, + this._resolve.parent(key, filePath), + ); + var meta = this._manifest.stats[this._resolveSymlink(filePath)]; + if (!meta) throw _enoent('lstat', filePath); + var s = _makeStats(meta); + return target === undefined + ? s + : shared.asSymlinkStat(s, toCallerPath(target)); } existsSync(filePath) { @@ -519,7 +587,18 @@ if (process.platform === 'win32') { }; } +// Every fs.readlink form comes through here. Encode only after the VFS has +// added the mount prefix, or hex/base64 targets would lose it. +var _origReadlinkSync = VirtualFileSystem.prototype.readlinkSync; +VirtualFileSystem.prototype.readlinkSync = function (linkPath, options) { + return shared.applyReadlinkEncoding( + toPlatformPath(_origReadlinkSync.call(this, linkPath)), + options, + ); +}; + virtualFs.mount(SNAPSHOT_PREFIX, { overlay: true }); + perf.end('vfs mount + hooks'); // ///////////////////////////////////////////////////////////////// diff --git a/test/test-99-#295/index.js b/test/test-99-#295/index.js index 2605c20f3..86d8beaab 100644 --- a/test/test-99-#295/index.js +++ b/test/test-99-#295/index.js @@ -8,10 +8,14 @@ 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. +// it. Required only through `lib`, so the walker must still record the link +// under `reallib/`, where it lives. require('./lib/inner.js'); +// `reallib/other.js` links to `log.js` too but is only required through the +// real directory, so the walker records it under `reallib/` and never `lib/`. +require('./reallib/other.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. @@ -42,7 +46,7 @@ assert.strictEqual( // 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'], + ['inner.js', 'log.js', 'other.js', 'package.json'], 'readdir of the linked directory', ); @@ -59,4 +63,167 @@ assert.throws( }, ); -log(42); +const S_IFMT = 0o170000; +const S_IFLNK = 0o120000; +const libPath = path.join(__dirname, 'lib'); +const realLib = path.join(__dirname, 'reallib'); +const viaParent = path.join(libPath, 'other.js'); +const notALink = path.join(__dirname, 'index.js'); + +// readlink answers the walker's absolute target, in the platform path form. +assert.strictEqual( + fs.readlinkSync(libPath), + realLib, + 'readlink of the directory link', +); +const asBuffer = fs.readlinkSync(libPath, 'buffer'); +assert.ok(Buffer.isBuffer(asBuffer), "readlink 'buffer' must give a Buffer"); +assert.strictEqual(asBuffer.toString(), realLib); +assert.ok(Buffer.isBuffer(fs.readlinkSync(libPath, { encoding: 'buffer' }))); +// Encoded after the mount prefix is added, so it decodes to the full path. +assert.strictEqual( + Buffer.from(fs.readlinkSync(libPath, 'hex'), 'hex').toString(), + realLib, + "readlink 'hex'", +); +if (nestedIsLink) { + const logPath = path.join(__dirname, 'reallib', 'log.js'); + assert.strictEqual(fs.readlinkSync(nested), logPath, 'readlink nested'); + assert.strictEqual( + fs.readlinkSync(path.join(realLib, 'inner.js')), + logPath, + 'readlink nested by its real path', + ); + assert.strictEqual( + fs.readlinkSync(viaParent), + logPath, + 'readlink via parent', + ); +} + +// EINVAL for a path that exists but is not a link, ENOENT for one that does +// not exist, and both name the caller's path. +assert.throws(() => fs.readlinkSync(notALink), { + code: 'EINVAL', + path: notALink, +}); +assert.throws(() => fs.readlinkSync(realLib), { + code: 'EINVAL', + path: realLib, +}); +// A plain file reached through the linked directory is not a link. +const logViaLink = path.join(libPath, 'log.js'); +assert.throws(() => fs.readlinkSync(logViaLink), { + code: 'EINVAL', + path: logViaLink, +}); +assert.strictEqual(fs.lstatSync(logViaLink).isSymbolicLink(), false); +assert.strictEqual(fs.lstatSync(logViaLink).isFile(), true); +const missing = path.join(__dirname, 'missing.js'); +assert.throws(() => fs.readlinkSync(missing), { + code: 'ENOENT', + path: missing, +}); +assert.throws(() => fs.lstatSync(missing), { code: 'ENOENT', path: missing }); + +// lstat describes a link itself, stat follows it, and a non-link is unchanged. +function assertLink(p, isDir) { + const l = fs.lstatSync(p); + assert.strictEqual(l.isSymbolicLink(), true, `lstat ${p} isSymbolicLink`); + assert.strictEqual(l.isDirectory(), false, `lstat ${p} isDirectory`); + assert.strictEqual(l.isFile(), false, `lstat ${p} isFile`); + assert.strictEqual(l.mode & S_IFMT, S_IFLNK, `lstat ${p} type bits`); + const s = fs.statSync(p); + assert.strictEqual(s.isSymbolicLink(), false, `stat ${p} isSymbolicLink`); + assert.strictEqual(s.isDirectory(), isDir, `stat ${p} isDirectory`); +} +assertLink(libPath, true); +if (nestedIsLink) { + assertLink(nested, false); + assertLink(path.join(realLib, 'inner.js'), false); + assertLink(viaParent, false); +} +assert.strictEqual(fs.lstatSync(notALink).isSymbolicLink(), false); +assert.strictEqual(fs.lstatSync(notALink).isFile(), true); +assert.strictEqual(fs.lstatSync(realLib).isDirectory(), true); + +// readdir reports links as links and agrees with lstat. +function direntOf(dir, name) { + const d = fs + .readdirSync(dir, { withFileTypes: true }) + .find((e) => e.name === name); + assert.ok(d, `${name} missing from readdir of ${dir}`); + assert.strictEqual(d.parentPath, dir, `${name} parentPath`); + return d; +} +assert.strictEqual(direntOf(__dirname, 'lib').isSymbolicLink(), true); +assert.strictEqual(direntOf(__dirname, 'reallib').isDirectory(), true); +assert.strictEqual( + direntOf(libPath, 'inner.js').isSymbolicLink(), + nestedIsLink, + 'readdir of the linked directory', +); +assert.strictEqual( + direntOf(realLib, 'inner.js').isSymbolicLink(), + nestedIsLink, + 'readdir of the real directory', +); +assert.strictEqual( + direntOf(libPath, 'other.js').isSymbolicLink(), + nestedIsLink, + 'readdir finds a link recorded under the real parent', +); + +// The callback and promise forms go through separate patches. +function viaCallback(fn, ...args) { + return new Promise((resolve, reject) => { + fn(...args, (err, value) => (err ? reject(err) : resolve(value))); + }); +} + +const pending = [ + viaCallback(fs.lstat, libPath).then((st) => + assert.strictEqual(st.isSymbolicLink(), true, 'fs.lstat'), + ), + fs.promises + .lstat(libPath) + .then((st) => + assert.strictEqual(st.isSymbolicLink(), true, 'fs.promises.lstat'), + ), + viaCallback(fs.readdir, __dirname, { withFileTypes: true }).then((list) => + assert.strictEqual( + list.find((e) => e.name === 'lib').isSymbolicLink(), + true, + 'fs.readdir withFileTypes', + ), + ), + fs.promises + .readdir(__dirname, { withFileTypes: true }) + .then((list) => + assert.strictEqual( + list.find((e) => e.name === 'lib').isSymbolicLink(), + true, + 'fs.promises.readdir withFileTypes', + ), + ), + viaCallback(fs.readlink, libPath).then((t) => + assert.strictEqual(t, realLib, 'fs.readlink'), + ), + fs.promises + .readlink(libPath) + .then((t) => assert.strictEqual(t, realLib, 'fs.promises.readlink')), + // An error reaches the callback rather than being thrown. + viaCallback(fs.readlink, notALink).then( + () => assert.fail('fs.readlink on a non-link must fail'), + (err) => assert.strictEqual(err.code, 'EINVAL', 'fs.readlink EINVAL'), + ), +]; + +// A call that never settles leaves log(42) unrun, which main.js reports. +Promise.all(pending).then( + () => log(42), + (err) => { + console.error(err); + process.exit(1); + }, +); diff --git a/test/test-99-#295/main.js b/test/test-99-#295/main.js index aaf6e8620..a5deaa49a 100644 --- a/test/test-99-#295/main.js +++ b/test/test-99-#295/main.js @@ -18,9 +18,10 @@ assert(__dirname === process.cwd()); // 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 otherLink = path.join(__dirname, 'reallib', 'other.js'); const linkInfo = path.join(__dirname, 'linkinfo.json'); const esmModules = path.join(__dirname, 'esm', 'node_modules'); -const generated = [libLink, innerLink, linkInfo, esmModules]; +const generated = [libLink, innerLink, otherLink, linkInfo, esmModules]; function removeGenerated() { for (const p of generated) utils.vacuum.sync(p); @@ -45,10 +46,14 @@ try { let nestedIsLink = true; try { fs.symlinkSync('log.js', innerLink, 'file'); + fs.symlinkSync('log.js', otherLink, '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); + for (const link of [innerLink, otherLink]) { + utils.vacuum.sync(link); + fs.copyFileSync(path.join(__dirname, 'reallib', 'log.js'), link); + } nestedIsLink = false; } fs.writeFileSync(linkInfo, `${JSON.stringify({ nestedIsLink }, null, 2)}\n`); diff --git a/test/unit/dirent-shared.test.ts b/test/unit/dirent-shared.test.ts new file mode 100644 index 000000000..055243e69 --- /dev/null +++ b/test/unit/dirent-shared.test.ts @@ -0,0 +1,166 @@ +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'); + +type Stat = { + mode: number; + size: number; + blocks: number; + nlink: number; + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +}; + +const { + Dirent, + asSymlinkStat, + linkTarget, + applyReadlinkEncoding, + UV_DIRENT_FILE, + UV_DIRENT_DIR, + UV_DIRENT_LINK, +} = shared as { + Dirent: new ( + _name: string, + _type: number, + _parentPath: string, + ) => { + name: string; + parentPath: string; + path?: string; + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + isBlockDevice(): boolean; + isCharacterDevice(): boolean; + isSocket(): boolean; + isFIFO(): boolean; + }; + asSymlinkStat: (_s: Stat, _target: string) => Stat; + linkTarget: ( + _symlinks: Record, + _resolvedKey: string, + ) => string | undefined; + applyReadlinkEncoding: ( + _target: string, + _options: unknown, + ) => string | Buffer; + UV_DIRENT_FILE: number; + UV_DIRENT_DIR: number; + UV_DIRENT_LINK: number; +}; + +const S_IFMT = 0o170000; +const S_IFLNK = 0o120000; +const S_IFREG = 0o100000; +const S_IFDIR = 0o040000; + +function throughLinkStat(mode: number): Stat { + return { + mode, + size: 4096, + blocks: 8, + nlink: 3, + isFile: () => (mode & S_IFMT) === S_IFREG, + isDirectory: () => (mode & S_IFMT) === S_IFDIR, + isSymbolicLink: () => false, + }; +} + +// These back readdir, lstat and readlink in both bootstraps, so a change here +// moves classic and SEA together. +describe('shared Dirent', () => { + it('reports exactly one type per libuv constant', () => { + const cases: [number, 'isFile' | 'isDirectory' | 'isSymbolicLink'][] = [ + [UV_DIRENT_FILE, 'isFile'], + [UV_DIRENT_DIR, 'isDirectory'], + [UV_DIRENT_LINK, 'isSymbolicLink'], + ]; + for (const [type, predicate] of cases) { + const d = new Dirent('entry', type, '/snapshot/app'); + for (const p of ['isFile', 'isDirectory', 'isSymbolicLink'] as const) { + assert.equal(d[p](), p === predicate, `${p} for type ${type}`); + } + } + }); + + it('answers false for device types', () => { + const d = new Dirent('node_modules', UV_DIRENT_LINK, '/snapshot/app'); + assert.equal(d.isBlockDevice(), false); + assert.equal(d.isCharacterDevice(), false); + assert.equal(d.isSocket(), false); + assert.equal(d.isFIFO(), false); + }); + + it('carries parentPath, and `path` only where Node still has it', () => { + const d = new Dirent('lib', UV_DIRENT_LINK, '/snapshot/app'); + assert.equal(d.parentPath, '/snapshot/app'); + const major = Number(process.versions.node.split('.')[0]); + assert.equal('path' in d, major < 24); + }); +}); + +describe('linkTarget', () => { + const symlinks = { + '/app/lib': '/app/reallib', + '/app/reallib/other.js': '/app/reallib/log.js', + }; + + it('finds a link by its parent-resolved key', () => { + assert.equal(linkTarget(symlinks, '/app/lib'), '/app/reallib'); + assert.equal( + linkTarget(symlinks, '/app/reallib/other.js'), + '/app/reallib/log.js', + ); + }); + + it('answers undefined for a non-link and for inherited keys', () => { + assert.equal(linkTarget(symlinks, '/app/x.js'), undefined); + assert.equal(linkTarget(symlinks, 'constructor'), undefined); + }); +}); + +describe('asSymlinkStat', () => { + it('flips the predicates a stat taken through the link got wrong', () => { + const s = asSymlinkStat(throughLinkStat(S_IFDIR | 0o755), '/snapshot/x'); + assert.equal(s.isSymbolicLink(), true); + assert.equal(s.isDirectory(), false); + assert.equal(s.isFile(), false); + }); + + it('rewrites the type bits without disturbing the permissions', () => { + for (const base of [S_IFREG | 0o644, S_IFDIR | 0o755]) { + const s = asSymlinkStat(throughLinkStat(base), '/snapshot/x'); + assert.equal(s.mode & S_IFMT, S_IFLNK); + assert.equal(s.mode & 0o777, base & 0o777); + } + }); + + it('sizes the link by its target string in bytes, with no blocks', () => { + const s = asSymlinkStat(throughLinkStat(S_IFREG | 0o644), '/snapshot/é'); + assert.equal(s.size, Buffer.byteLength('/snapshot/é')); + assert.equal(s.blocks, 0); + assert.equal(s.nlink, 1); + }); +}); + +describe('readlink encoding', () => { + it('answers a Buffer for buffer, re-encodes others, passes utf8 through', () => { + const b = applyReadlinkEncoding('/a', { encoding: 'buffer' }); + assert.ok(Buffer.isBuffer(b)); + assert.equal(b.toString(), '/a'); + assert.ok(Buffer.isBuffer(applyReadlinkEncoding('/a', 'buffer'))); + assert.equal( + applyReadlinkEncoding('/a', 'hex'), + Buffer.from('/a').toString('hex'), + ); + assert.equal(applyReadlinkEncoding('/a', 'utf8'), '/a'); + assert.equal(applyReadlinkEncoding('/a', 'utf-8'), '/a'); + assert.equal(applyReadlinkEncoding('/a', undefined), '/a'); + assert.equal(applyReadlinkEncoding('/a', null), '/a'); + assert.equal(applyReadlinkEncoding('/a', {}), '/a'); + }); +}); diff --git a/test/unit/resolve-symlink.test.ts b/test/unit/resolve-symlink.test.ts index 5ec112229..24c6da6a2 100644 --- a/test/unit/resolve-symlink.test.ts +++ b/test/unit/resolve-symlink.test.ts @@ -6,7 +6,9 @@ const shared = createRequire(__filename)('../../prelude/bootstrap-shared.js'); const makeSymlinkResolver = shared.makeSymlinkResolver as ( _symlinks: Record, _sep: string, -) => (_p: string, _display?: string) => string; +) => ((_p: string, _display?: string) => string) & { + parent: (_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. @@ -65,7 +67,7 @@ describe('makeSymlinkResolver', () => { }); it('follows a directory symlink nested inside another one', () => { - // The walker keys `/sub` on the path it walked, through ``. + // An exact key beats its symlinked parent. const resolve = makeSymlinkResolver( { '/app/lib': '/app/reallib', @@ -81,8 +83,27 @@ describe('makeSymlinkResolver', () => { assert.equal(resolve('/app/lib/other.js'), '/app/reallib/other.js'); }); + it('follows a nested link keyed under the real directory', () => { + // The shape the walker emits: the inner link is keyed under reallib/. + const resolve = makeSymlinkResolver( + { + '/app/lib': '/app/reallib', + '/app/reallib/sub': '/app/reallib/realsub', + }, + '/', + ); + assert.equal( + resolve('/app/lib/sub/file.js'), + '/app/reallib/realsub/file.js', + ); + assert.equal( + resolve('/app/reallib/sub/file.js'), + '/app/reallib/realsub/file.js', + ); + }); + it('prefers an exact entry over its symlinked parent', () => { - // Same shape as test-99-#295/lib/inner.js. + // Both keys exist; the exact (more specific) one must win. const resolve = makeSymlinkResolver( { '/app/lib': '/app/reallib', @@ -154,6 +175,59 @@ describe('makeSymlinkResolver', () => { ); }); + describe('resolveKey.parent — the readlink/lstat step', () => { + // POSIX resolves a path's parents before reading its last component, so an + // entry recorded under an already-followed parent stays reachable. Both + // bootstraps drive this, which is why it lives on the resolver. + it('resolves the parents and keeps the last component', () => { + const resolve = makeSymlinkResolver( + { '/snapshot/lib': '/snapshot/reallib' }, + '/', + ); + assert.equal( + resolve.parent('/snapshot/lib/inner.js'), + '/snapshot/reallib/inner.js', + ); + }); + + it('does not follow the last component itself', () => { + // The whole point: resolve() would answer /snapshot/reallib here. + const resolve = makeSymlinkResolver( + { '/snapshot/lib': '/snapshot/reallib' }, + '/', + ); + assert.equal(resolve.parent('/snapshot/lib'), '/snapshot/lib'); + }); + + it('is present on the empty-manifest fast path too', () => { + // The identity resolver is what a symlink-free binary gets, which is + // most of them — readlink and lstat call .parent unconditionally. + const resolve = makeSymlinkResolver({}, '/'); + assert.equal(typeof resolve.parent, 'function'); + assert.equal( + resolve.parent('/snapshot/app/lib/inner.js'), + '/snapshot/app/lib/inner.js', + ); + }); + + it('leaves a key with no parent alone', () => { + const resolve = makeSymlinkResolver({ '/a': '/b' }, '/'); + assert.equal(resolve.parent('/a'), '/a'); + assert.equal(resolve.parent('a'), 'a'); + }); + + it('walks win32 keys on their own separator', () => { + const resolve = makeSymlinkResolver( + { 'C:\\snapshot\\lib': 'C:\\snapshot\\reallib' }, + '\\', + ); + assert.equal( + resolve.parent('C:\\snapshot\\lib\\inner.js'), + 'C:\\snapshot\\reallib\\inner.js', + ); + }); + }); + describe('the MAX_SYMLINK_DEPTH bound', () => { // Chain of `n` links ending at '/end': /l0 -> /l1 -> ... -> /ln -> /end. const chain = (n: number) => { diff --git a/yarn.lock b/yarn.lock index 52b176599..f47c508df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -636,10 +636,10 @@ conventional-recommended-bump "^11.2.0" semver "^7.7.3" -"@roberts_lando/vfs@^0.3.3": - version "0.3.3" - resolved "https://registry.yarnpkg.com/@roberts_lando/vfs/-/vfs-0.3.3.tgz#6359feed30e3773041279cb11be82ca60f4ff1f1" - integrity sha512-YjkxVSLw5WMZQoARaryRAjcxA+GbBzWMJdwYZX5oLUt9cC/gew9as4Dn7tcLzPp7BPoR221VpTZ+78TRPawnjg== +"@roberts_lando/vfs@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@roberts_lando/vfs/-/vfs-0.4.0.tgz#70b33766cded298650282e4ef18743490599c3c5" + integrity sha512-8gk53fJyCgTHEqJcMnwcuSyJA12w42uP2LWBL8Zfnzu7s/A7how1HBN+xbU5d6Hwqa5al16++TcnZ1lIYd+ImQ== "@rtsao/scc@^1.1.0": version "1.1.0"