Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`][] and [`--vfs-load`][] startup flags, which are only allowed
when `--experimental-vfs` is set.

### `--experimental-vm-modules`

Expand Down Expand Up @@ -3562,6 +3564,89 @@ added: v0.1.3

Print node's version.

### `--vfs-load`

<!-- YAML
added: REPLACEME
-->

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 <mountPoint>` 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]`

<!-- YAML
added: REPLACEME
-->

* `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). Paths under `target` then
resolve against the mount - both `require()`/`import` and the running
program's own [`node:fs`][] calls - while every other path uses the real file
system unchanged. Mounting alone does **not** change the entry point; pass
[`--vfs-load`][] to also run from a mount.

* 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`][] or [`--import`][]) 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`.

Provider selection is deferred until after [`--require`][] and [`--import`][]
preload modules have run, so a preloaded module can register a custom provider
that backs the mount.

This only affects the paths under a mount. The running program's own
[`node:fs`][] calls to other paths work normally against the real file system,
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-mount` inherits the
same mounts unless its own `execArgv` explicitly supplies its own
`--vfs-mount`.

### `--watch`

<!-- YAML
Expand Down Expand Up @@ -3981,6 +4066,8 @@ one is included in the list below.
* `--use-openssl-ca`
* `--use-system-ca`
* `--v8-pool-size`
* `--vfs-load`
* `--vfs-mount`
* `--watch-kill-signal`
* `--watch-path`
* `--watch-preserve-output`
Expand Down Expand Up @@ -4484,6 +4571,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`--env-file-if-exists`]: #--env-file-if-existsfile
[`--env-file`]: #--env-filefile
[`--experimental-sea-config`]: single-executable-applications.md#1-generating-single-executable-preparation-blobs
[`--experimental-vfs`]: #--experimental-vfs
[`--heap-prof-dir`]: #--heap-prof-dir
[`--import`]: #--importmodule
[`--no-require-module`]: #--no-require-module
Expand All @@ -4495,6 +4583,8 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`--require`]: #-r---require-module
[`--use-env-proxy`]: #--use-env-proxy
[`--use-system-ca`]: #--use-system-ca
[`--vfs-load`]: #--vfs-load
[`--vfs-mount`]: #--vfs-mountsourcetarget
[`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage
[`Buffer`]: buffer.md#class-buffer
[`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html
Expand All @@ -4503,15 +4593,19 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`NODE_OPTIONS`]: #node_optionsoptions
[`NODE_USE_ENV_PROXY=1`]: #node_use_env_proxy1
[`NO_COLOR`]: https://no-color.org
[`RealFSProvider`]: vfs.md#class-realfsprovider
[`Web Storage`]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API
[`Worker`]: worker_threads.md#class-worker
[`YoungGenerationSizeFromSemiSpaceSize`]: https://chromium.googlesource.com/v8/v8.git/+/refs/tags/10.3.129/src/heap/heap.cc#328
[`ZipProvider`]: vfs.md#class-zipprovider
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
[`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorder
[`dnsPromises.lookup()`]: dns.md#dnspromiseslookuphostname-options
[`import.meta.url`]: esm.md#importmetaurl
[`import` specifier]: esm.md#import-specifiers
[`net.getDefaultAutoSelectFamilyAttemptTimeout()`]: net.md#netgetdefaultautoselectfamilyattempttimeout
[`node:ffi`]: ffi.md
[`node:fs`]: fs.md
[`node:sqlite`]: sqlite.md
[`node:stream/iter`]: stream_iter.md
[`node:vfs`]: vfs.md
Expand All @@ -4522,6 +4616,8 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`v8.startupSnapshot.addDeserializeCallback()`]: v8.md#v8startupsnapshotadddeserializecallbackcallback-data
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
[`vfs.registerProvider()`]: vfs.md#vfsregisterproviderentry
[`zlib.ZipFile`]: zlib.md#class-zlibzipfile
[asynchronous module customization hooks]: module.md#asynchronous-customization-hooks
[captured by the built-in snapshot of Node.js]: https://github.com/nodejs/node/blob/b19525a33cc84033af4addd0f80acd4dc33ce0cf/test/parallel/test-bootstrap-modules.js#L24
[collecting code coverage from tests]: test.md#collecting-code-coverage
Expand Down
8 changes: 8 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -3477,6 +3477,13 @@ An attempt was made to use something that was already closed.
While using the Performance Timing API (`perf_hooks`), no valid performance
entry types are found.

<a id="ERR_VFS_INVALID_TARGET"></a>

### `ERR_VFS_INVALID_TARGET`

A [`--vfs-mount`][] source does not exist, is neither a regular file nor a
directory, or is a file no provider claims.

<a id="ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING"></a>

### `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`
Expand Down Expand Up @@ -4647,6 +4654,7 @@ An error occurred trying to allocate memory. This should never happen.
[`--force-fips`]: cli.md#--force-fips
[`--no-addons`]: cli.md#--no-addons
[`--unhandled-rejections`]: cli.md#--unhandled-rejectionsmode
[`--vfs-mount`]: cli.md#--vfs-mountsourcetarget
[`BoundSocket`]: net.md#class-netboundsocket
[`Class: assert.AssertionError`]: assert.md#class-assertassertionerror
[`ERR_INCOMPATIBLE_OPTION_PAIR`]: #err_incompatible_option_pair
Expand Down
89 changes: 86 additions & 3 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ It does not isolate untrusted code from the host file system or from other
Node.js capabilities. Code that can access a [`VirtualFileSystem`][] instance,
mount it, select its provider, or pass paths to it is trusted application code.

Mounting a VFS only redirects supported [`node:fs`][] calls whose resolved paths
are under the mount point. It does not prevent code from using other paths or
other Node.js APIs to access resources available to the process.
Mounting a VFS only redirects supported [`node:fs`][] calls (and, since the
CJS/ESM module loader ultimately resolves and reads files the same way,
`require()`/`import` resolution) whose resolved paths are under the mount
point. It does not prevent code from using other paths or other Node.js APIs
to access resources available to the process.
[`RealFSProvider`][] maps VFS paths under its configured root and rejects paths
that resolve outside that root, but that check is not a security boundary.
[`ZipProvider`][] has no real file-system paths of its own to escape; its
Expand Down Expand Up @@ -88,6 +90,79 @@ const memoryVfs = vfs.create();
const realVfs = vfs.create(new vfs.RealFSProvider('/tmp/vfs-root'));
```

## `vfs.registerProvider(entry)`

<!-- YAML
added: REPLACEME
-->

* `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`][] or [`--import`][],
so it is in place before `--vfs-mount` selects a provider (selection is
deferred until after both kinds of preload have run):

```console
$ node --experimental-vfs -r @me/my-7z-provider --vfs-load --vfs-mount app.7z
```

```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`][] to record every read, 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`

<!-- YAML
Expand Down Expand Up @@ -168,6 +243,10 @@ signatures as their [`node:fs`][] counterparts:
`fstatSync`
* Streams: `createReadStream`, `createWriteStream`
* Watchers: `watch`, `watchFile`, `unwatchFile`
* `toProviderPath(path)`: converts an absolute mounted path to the
provider-relative POSIX path passed to [`VirtualProvider`][] methods -
useful for code that needs to reach `vfs.provider` directly, bypassing
this class's own `fs`-shaped methods.

#### Callback API

Expand Down Expand Up @@ -367,6 +446,9 @@ fields use synthetic but stable values:
* `blocks` is `Math.ceil(size / 512)`.
* Times default to the moment the entry was created/last modified.

[`--import`]: cli.md#--importmodule
[`--require`]: cli.md#-r---require-module
[`--vfs-mount`]: cli.md#--vfs-mountsourcetarget
[`MemoryProvider`]: #class-memoryprovider
[`RealFSProvider`]: #class-realfsprovider
[`VirtualFileSystem`]: #class-virtualfilesystem
Expand All @@ -375,6 +457,7 @@ fields use synthetic but stable values:
[`fs.BigIntStats`]: fs.md#class-fsbigintstats
[`fs.Stats`]: fs.md#class-fsstats
[`node:fs`]: fs.md
[`vfs.create()`]: #vfscreateprovider-options
[`zipFile.writable`]: zlib.md#zipfilewritable
[`zlib.ZipBuffer`]: zlib.md#class-zlibzipbuffer
[`zlib.ZipFile`]: zlib.md#class-zlibzipfile
63 changes: 62 additions & 1 deletion doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,9 @@ filter value to run. See Test tags for details on declaring and
inheriting tags.
.
.It Fl -experimental-vfs
Enable the experimental \fBnode:vfs\fR module.
Enable the experimental \fBnode:vfs\fR module. This flag also gates the
\fB--vfs-mount\fR and \fB--vfs-load\fR startup flags, which are only allowed
when \fB--experimental-vfs\fR is set.
.
.It Fl -experimental-vm-modules
Enable experimental ES Module support in the \fBnode:vm\fR module.
Expand Down Expand Up @@ -1775,6 +1777,61 @@ amount of CPUs, but it may diverge in environments such as VMs or containers.
.It Fl v , Fl -version
Print node's version.
.
.It Fl -vfs-load
Requires \fB--experimental-vfs\fR and at least one \fB--vfs-mount\fR.
Runs the entry point (\fBprocess.argv[1]\fR) and all subsequent
\fBrequire()\fR/\fBimport\fR resolution against the last \fB--vfs-mount\fR rather
than the real file system. \fBprocess.argv[1]\fR becomes that mount's root, as
if \fBnode <mountPoint>\fR had been run: the mount's own \fBpackage.json\fR
\fB"main"\fR (or \fBindex.js\fR) selects the entry point, and any positional
command-line argument is the program's own (available from \fBprocess.argv[2]\fR
onward), never an entry-point override.
Module resolution under the loaded mount is fully sandboxed: \fBpackage.json\fR
lookups, \fBnode_modules\fR-style resolution, and legacy \fBmain\fR 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:
.Bd -literal
$ (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']
.Ed
.
.It Fl -vfs-mount Ns = Ns Ar source Ns Oo = Ns Ar target Oc
.Bl -bullet
.It
\fBsource\fR \fB<string>\fR A directory or an archive file to mount.
.It
\fBtarget\fR \fB<string>\fR Where to mount it. Default: \fBsource\fR's own
resolved path.
.El
Requires \fB--experimental-vfs\fR. May be repeated to mount several sources.
Mounts \fBsource\fR as a virtual file system (\fBnode:vfs\fR) at \fBtarget\fR (or
at \fBsource\fR's own path when no \fBtarget\fR is given). Paths under
\fBtarget\fR then resolve against the mount - both \fBrequire()\fR/\fBimport\fR
and the running program's own \fBnode:fs\fR calls - while every other path uses
the real file system unchanged. Mounting alone does not change the entry
point; pass \fB--vfs-load\fR to also run from a mount.
.Bl -bullet
.It
If \fBsource\fR is a directory, it's mounted with a \fBRealFSProvider\fR rooted
there, adding path containment.
.It
If \fBsource\fR is a file, a provider is chosen for it by content, not by file
extension. Providers registered with \fBvfs.registerProvider()\fR (from a
\fB--require\fR or \fB--import\fR preload) are tried first, for directories as
well as files; otherwise the built-in ZIP provider handles a file whose bytes
are a ZIP archive. A source no provider claims fails with
\fBERR_VFS_INVALID_TARGET\fR.
.El
Native addons (\fB.node\fR 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.
A \fBWorker\fR created from a process started with \fB--vfs-mount\fR inherits the
same mounts unless its own \fBexecArgv\fR explicitly supplies its own
\fB--vfs-mount\fR.
.
.It Fl -watch
Starts Node.js in watch mode.
When in watch mode, changes in the watched files cause the Node.js process to
Expand Down Expand Up @@ -2235,6 +2292,10 @@ one is included in the list below.
.It
\fB--v8-pool-size\fR
.It
\fB--vfs-load\fR
.It
\fB--vfs-mount\fR
.It
\fB--watch-kill-signal\fR
.It
\fB--watch-path\fR
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1952,6 +1952,8 @@ E('ERR_USE_AFTER_CLOSE', '%s was closed', Error);
// This should probably be a `TypeError`.
E('ERR_VALID_PERFORMANCE_ENTRY_TYPE',
'At least one valid performance entry type is required', Error);
E('ERR_VFS_INVALID_TARGET',
'%s is not a valid --vfs-mount source: must be an existing file or directory', Error);
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING',
'A dynamic import callback was not specified.', TypeError);
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG',
Expand Down
Loading