From 4fe87bc4ad042ad3c615f88e3e51f741bc62e061 Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Wed, 8 Jul 2026 13:21:44 +0200 Subject: [PATCH] vfs: add --vfs and --vfs-manifest startup flags Adds --vfs=, which mounts a directory or ZIP archive and resolves the entry point (process.argv[1]) and all subsequent require()/import resolution against it instead of the real filesystem. A directory target is mounted with RealFSProvider at its own real path, gaining path containment it wouldn't otherwise have; a file target is opened as a read-only ZIP archive (zlib.ZipFile) and mounted with ZipProvider, turning that path into a virtual directory. Only paths under the mount are affected - the running program's own node:fs calls to other real paths are untouched, and module resolution never falls back to the real filesystem once it would step outside the mounted target. With --vfs active, process.argv[1] is unconditionally the mount root, exactly as if `node ` had been run - so the mount's own package.json "main"/index.js decides what runs, and any positional argument is the program's own (shifted to argv[2] onward), never an entry-point override. This makes a self-mounting shebang line (`#!/usr/bin/env node --vfs`) work: the kernel appends the script's own path as the argument that becomes --vfs's value, so the user's first real argument would otherwise land in argv[1] and be misread as an entry path. An active --vfs mount is also treated as an entry point by the C++ startup dispatch, so `node --vfs=` with no positional argument runs the mount rather than falling through to the REPL or stdin. Workers are unaffected: they name their entry through `new Worker(filename)`, resolved against the inherited mount, not argv[1]. Getting there requires four of the CJS/ESM loader's module-resolution primitives - package.json reading, nearest-parent/scope lookup, legacy main resolution, and extensionless-file format sniffing - to stop bypassing the public fs module and calling straight into native bindings, since that bypass is exactly what let them ignore the mount. Each gets a VFS-aware replacement that defers to the real native binding unchanged for anything outside an active mount, so behavior for non-mounted paths is identical to before. Native addons are supported: a directory-backed mount dlopens the real underlying file directly; an archive-backed mount extracts the addon to a content-hashed temp file first, since there's no real file to point at. Worker threads inherit an active mount automatically in the common case, and explicitly when the caller supplies its own execArgv that omits it, so sandboxed code can't spawn an "escaped" worker. Also adds --vfs-manifest=, used with a directory --vfs target: the path of every file actually read through the mount - by module resolution or by the program's own node:fs calls - is appended to as it's read, via a small observer hook on the provider base class rather than patching any method. Workers append to the same file directly, since they share the real filesystem with the main thread. Native addons extracted from an archive-backed VFS mount were written under a single shared node-vfs-addons temp directory, keyed only by content hash. Sharing that directory across processes lets one process delete or overwrite a file another process still has dlopen'd/mapped. Scope the directory per pid (node-vfs-addons-) so each process owns its own extraction cache. Signed-off-by: Philipp Dunkel --- doc/api/cli.md | 104 ++++++- doc/api/errors.md | 17 ++ doc/api/vfs.md | 12 +- doc/node.1 | 84 +++++- lib/internal/errors.js | 4 + lib/internal/modules/cjs/loader.js | 8 +- lib/internal/modules/esm/get_format.js | 5 +- lib/internal/modules/esm/resolve.js | 8 +- lib/internal/modules/package_json_reader.js | 20 +- lib/internal/modules/run_main.js | 2 +- lib/internal/modules/vfs_addons.js | 106 ++++++++ lib/internal/modules/vfs_resolution.js | 287 ++++++++++++++++++++ lib/internal/process/pre_execution.js | 102 ++++++- lib/internal/vfs/cli_manifest.js | 78 ++++++ lib/internal/vfs/file_system.js | 12 + lib/internal/vfs/provider.js | 24 +- lib/internal/vfs/providers/real.js | 287 +++++++++++++------- lib/internal/vfs/setup.js | 1 + lib/internal/worker.js | 45 ++- src/node.cc | 7 +- src/node_options.cc | 21 ++ src/node_options.h | 2 + test/parallel/test-vfs-cli-flag-addons.js | 80 ++++++ test/parallel/test-vfs-cli-flag.js | 212 +++++++++++++++ test/parallel/test-vfs-manifest-flag.js | 140 ++++++++++ test/parallel/test-vfs-mount-require.js | 73 +++++ 26 files changed, 1610 insertions(+), 131 deletions(-) create mode 100644 lib/internal/modules/vfs_addons.js create mode 100644 lib/internal/modules/vfs_resolution.js create mode 100644 lib/internal/vfs/cli_manifest.js create mode 100644 test/parallel/test-vfs-cli-flag-addons.js create mode 100644 test/parallel/test-vfs-cli-flag.js create mode 100644 test/parallel/test-vfs-manifest-flag.js create mode 100644 test/parallel/test-vfs-mount-require.js diff --git a/doc/api/cli.md b/doc/api/cli.md index 8c241e7202bdb8..e78dc6dd34173e 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1492,7 +1492,9 @@ added: v26.4.0 > Stability: 1 - Experimental -Enable the experimental [`node:vfs`][] module. +Enable the experimental [`node:vfs`][] module. This flag also gates the +[`--vfs`][] and [`--vfs-manifest`][] startup flags, which are only allowed +when `--experimental-vfs` is set. ### `--experimental-vm-modules` @@ -3532,6 +3534,95 @@ added: v0.1.3 Print node's version. +### `--vfs-manifest=file` + + + +* `file` {string} Where to write the manifest. Must be an explicit path - + there is no default derived from the [`--vfs`][] target, so a run can + never silently overwrite the wrong file. + +Requires [`--experimental-vfs`][]. Used together with a directory [`--vfs`][] +target. The path of every file actually read through the mount during this +run - by module resolution or by the program's own [`node:fs`][] calls - is +appended, one per line, to `file` as soon as it's read. + +```bash +node --vfs=./my-app --vfs-manifest=./my-app.manifest main.js +# -> ./my-app.manifest lists every file this run actually touched +``` + +This lets you exercise an app once against its real source directory and +come away with exactly the list of files it needed, without hand-picking +what to bundle - useful as an input to building an archive of just those +files, for example to run later via `--vfs=apparchive.zip`. Throws +[`ERR_VFS_MANIFEST_REQUIRES_DIRECTORY`][] if [`--vfs`][]'s target is a file +rather than a directory. + +Entries are appended immediately as each file is read, not buffered and +flushed at the end, so the manifest reflects everything read up to +whatever point the process reaches - including a process that's killed +rather than exiting normally. Duplicate reads of the same file are only +recorded once per thread; a [`Worker`][] appends to the very same manifest +file directly, since it shares the real file system with the main thread. + +### `--vfs=target` + + + +* `target` {string} A directory or a ZIP archive to mount. + +Requires [`--experimental-vfs`][]. + +Mounts `target` as a virtual file system ([`node:vfs`][]) and resolves the +entry point (`process.argv[1]`) and all subsequent `require()`/`import` +resolution against it, instead of against the real file system. + +* If `target` is a directory, it's mounted with a [`RealFSProvider`][] rooted + there. The files are already real, so mounting doesn't change what bytes + are read - it adds path containment, rejecting resolution that would + escape above `target` via `..`. +* If `target` is a file, it's opened as a ZIP archive ([`zlib.ZipFile`][], + read-only) and mounted with a [`ZipProvider`][], turning `target` + itself into a virtual directory for module-resolution purposes (e.g. + `--vfs=/path/to/app.zip` resolves `/path/to/app.zip/index.js` inside the + archive). + +`process.argv[1]` becomes the mount root itself, as if `node ` had +been run: the mount's own `package.json` `"main"` (or `index.js`) selects the +entry point, and any positional command-line argument is the program's own +(available from `process.argv[2]` onward), never an entry-point override. So a +ZIP archive can be made directly executable by prepending a shebang line and +marking it executable: + +```console +$ (printf '#!/usr/bin/env -S node --vfs\n'; cat app.zip) > app && chmod +x app +$ ./app arg1 arg2 # runs the archive's index.js with ['arg1', 'arg2'] +``` + +Module resolution under `--vfs` is fully sandboxed: `package.json` lookups, +`node_modules`-style resolution, and legacy `main` resolution never fall back +to the real file system once they would step outside the mounted target - a +dependency has to be inside the mounted directory or archive to be found. + +This only affects module _resolution_. The running program's own [`node:fs`][] +calls to paths outside the mounted target work normally against the real file +system; only paths under `target` are redirected, the same as any other +[`node:vfs`][] mount. + +Native addons (`.node` files) are supported: from a directory-backed mount +they're loaded directly from their real underlying path; from an +archive-backed mount, the addon's bytes are extracted to a content-hashed +file under the OS temporary directory before being loaded, and that file is +best-effort removed when the process exits. + +A [`Worker`][] created from a process started with `--vfs` inherits the same +mount unless its own `execArgv` explicitly overrides it. + ### `--watch`