From 98deeb23e8afc322c886f6065718dec92b11fe27 Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Sat, 1 Aug 2026 20:21:12 +0200 Subject: [PATCH 1/2] vfs: add --vfs-mount startup flag Adds --vfs-mount=[=], which mounts a directory or archive file as a virtual file system (node:vfs) at , or at 's own resolved path when no is given. The running program's own node:fs calls to paths under a mount resolve against it, while every other path uses the real file system unchanged. The flag may be repeated to mount several sources, gated by --experimental-vfs. Provider selection is pluggable and content-based, never by file extension. vfs.registerProvider() lets a module - typically preloaded with -r - register a provider for a directory or archive format; registered providers are tried first (newest wins, for directories as well as files), so a custom provider can back, wrap, or vet any mount. If none claims a source, the built-ins handle it: a directory with RealFSProvider, and a file whose bytes are a ZIP archive with the read-only ZipProvider. A ZIP is recognized by locating its end-of-central-directory record - so an archive can carry any name and even a prefix; a .zip name is accepted without reading, as a fast path. A source no provider claims fails with ERR_VFS_INVALID_TARGET. Workers inherit the parent's --vfs-mount targets unless they supply their own execArgv with a --vfs-mount of their own. Signed-off-by: Philipp Dunkel --- doc/api/cli.md | 51 ++++- doc/api/errors.md | 8 + doc/api/vfs.md | 75 +++++++ doc/node.1 | 34 ++- lib/internal/errors.js | 2 + lib/internal/process/pre_execution.js | 52 +++++ lib/internal/vfs/provider_registry.js | 142 ++++++++++++ lib/internal/vfs/providers/real.js | 275 +++++++++++++++-------- lib/internal/worker.js | 45 +++- lib/vfs.js | 2 + src/node_options.cc | 10 + src/node_options.h | 1 + test/parallel/test-vfs-mount-flag.js | 104 +++++++++ test/parallel/test-vfs-mount-provider.js | 147 ++++++++++++ 14 files changed, 847 insertions(+), 101 deletions(-) create mode 100644 lib/internal/vfs/provider_registry.js create mode 100644 test/parallel/test-vfs-mount-flag.js create mode 100644 test/parallel/test-vfs-mount-provider.js diff --git a/doc/api/cli.md b/doc/api/cli.md index 9434e19d3b73..fa7db08d71dd 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1496,7 +1496,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-mount`][] startup flag, which is only allowed when `--experimental-vfs` +is set. ### `--experimental-vm-modules` @@ -3562,6 +3564,44 @@ added: v0.1.3 Print node's version. +### `--vfs-mount=source[=target]` + + + +* `source` {string} A directory or an archive file to mount. +* `target` {string} Where to mount it. **Default:** `source`'s own resolved + path. + +Requires [`--experimental-vfs`][]. May be repeated to mount several sources. + +Mounts `source` as a virtual file system ([`node:vfs`][]) at `target` (or at +`source`'s own path when no `target` is given). The running program's own +[`node:fs`][] calls to paths under `target` then resolve against the mount, +while every other path uses the real file system unchanged. + +* If `source` 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 the + root via `..`. +* If `source` is a file, a provider is chosen for it by **content**, not by + file extension, so an archive can carry any name. Providers registered with + [`vfs.registerProvider()`][] (typically from a module preloaded with + [`--require`][]) are tried first, in reverse registration order and for + directories as well as files; if none claims the source, the built-in + providers handle it - a directory with [`RealFSProvider`][], and a file whose + bytes are a ZIP archive with the read-only [`ZipProvider`][] + ([`zlib.ZipFile`][]; a `.zip` name is accepted without reading, as a fast + path). A source no provider claims fails with `ERR_VFS_INVALID_TARGET`. + +This affects only paths under a mount, the same as any other [`node:vfs`][] +mount. + +A [`Worker`][] created from a process started with `--vfs-mount` inherits the +same mounts unless its own `execArgv` explicitly supplies its own +`--vfs-mount`. + ### `--watch` + +* `entry` {Object} + * `name` {string} A short identifier for the provider, used in diagnostics. + * `canHandle` {Function} `(resolvedPath, stats) => boolean`. Returns `true` + if this provider should back `resolvedPath`. `stats` is the + `fs.statSync()` result, so a provider can claim directories, files, or + both. Prefer inspecting the stats and (for archives) the contents - for + example, sniffing a magic-number signature - over trusting the file + extension, so an archive can carry any name. + * `create` {Function} `(resolvedPath, stats) => VirtualProvider`. Returns the + provider that backs `resolvedPath`. Only ever called after `canHandle` + returned `true` for the same path. + +Registers a provider that the [`--vfs-mount`][] startup flag can select for a +mount source it recognizes. This is the extension point for supporting archive +formats beyond the built-in ZIP, or for wrapping the built-in directory and +ZIP providers: a module that implements, say, a 7-Zip provider registers it +here — typically from a module preloaded with [`--require`][], so it is in +place before `--vfs-mount` selects a provider: + +```console +$ node --experimental-vfs -r @me/my-7z-provider --vfs-mount app.7z app.js +``` + +```cjs +// @me/my-7z-provider (the preloaded module) +const vfs = require('node:vfs'); +const { SevenZipProvider } = require('./provider'); + +vfs.registerProvider({ + name: '7z', + // Recognize by the 7-Zip signature, not the file name. + canHandle(resolvedPath, stats) { + if (!stats.isFile()) return false; + const fd = require('fs').openSync(resolvedPath, 'r'); + try { + const magic = Buffer.alloc(6); + require('fs').readSync(fd, magic, 0, 6, 0); + return magic.equals(Buffer.from([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])); + } finally { + require('fs').closeSync(fd); + } + }, + create(resolvedPath) { return new SevenZipProvider(resolvedPath); }, +}); +``` + +Selection rules for a `--vfs-mount` source: + +* Registered providers are consulted first, in reverse registration order (the + most recently registered wins), so a custom provider always takes precedence + over the built-ins — even for a source they would otherwise handle. This lets + a provider back, wrap, or vet any mount, including a directory (for example, + a provider that wraps [`RealFSProvider`][], or one that verifies a signature + before allowing use). +* If no registered provider claims the source, the built-ins handle it: a + directory with [`RealFSProvider`][], and a file whose bytes are a ZIP archive + with the built-in ZIP provider. A `.zip` name is accepted without reading the + file, as a fast path; any other name is recognized by locating the archive's + end-of-central-directory record. +* If no provider claims the source, `--vfs-mount` fails with + `ERR_VFS_INVALID_TARGET`. + +Registration is process-wide and affects only how the [`--vfs-mount`][] flag +chooses a provider; it does not change how [`vfs.create()`][] or +`new ZipProvider()` behave when a provider is passed explicitly. + ## Class: `VirtualFileSystem` + +Requires [`--experimental-vfs`][] and at least one [`--vfs-mount`][]. + +Runs the entry point (`process.argv[1]`) and all subsequent +`require()`/`import` resolution against the **last** [`--vfs-mount`][] rather +than the real file system. `process.argv[1]` becomes that mount's root, 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. + +Module resolution under the loaded mount 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 mount. + +Combined with a self-mounting shebang this makes an archive directly +executable. The kernel appends the script's own path as the trailing argument, +which the final `--vfs-mount` consumes as its source, so the archive mounts +itself and runs (the ZIP is located by its trailing record, so the shebang +prefix is ignored): + +```console +$ (printf '#!/usr/bin/env -S node --vfs-load --vfs-mount\n'; cat app.zip) > app +$ chmod +x app +$ ./app arg1 arg2 # runs the archive's index.js with ['arg1', 'arg2'] +``` + ### `--vfs-mount=source[=target]`