Skip to content
Draft
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
51 changes: 50 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`][] startup flag, which is only allowed when `--experimental-vfs`
is set.

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

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

Print node's version.

### `--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). 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`

<!-- YAML
Expand Down Expand Up @@ -3981,6 +4021,7 @@ one is included in the list below.
* `--use-openssl-ca`
* `--use-system-ca`
* `--v8-pool-size`
* `--vfs-mount`
* `--watch-kill-signal`
* `--watch-path`
* `--watch-preserve-output`
Expand Down Expand Up @@ -4484,6 +4525,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 +4537,7 @@ 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-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 +4546,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 +4569,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
138 changes: 134 additions & 4 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ 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.
[`RealFSProvider`][] maps VFS paths under its configured root and rejects paths
that resolve outside that root, but that check is not a security boundary. Do
not rely on VFS to run untrusted code; use operating-system-level isolation,
such as separate users, containers, or platform sandboxes, when a security
boundary is required.
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
entries only ever exist within the archive's own namespace. Do not rely on VFS
to run untrusted code; use operating-system-level isolation, such as separate
users, containers, or platform sandboxes, when a security boundary is
required.

## Basic usage

Expand Down Expand Up @@ -86,6 +88,78 @@ 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`][], 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`

<!-- YAML
Expand Down Expand Up @@ -302,6 +376,55 @@ added: v26.4.0

The resolved absolute path used as the root.

## Class: `ZipProvider`

<!-- YAML
added: REPLACEME
-->

A provider that exposes the entries of a ZIP archive - either a
[`zlib.ZipBuffer`][] (in memory) or a [`zlib.ZipFile`][] (on disk) - through
the VFS API. `provider.readonly` reflects the archive's own
[`zipFile.writable`][] flag: a `ZipBuffer` is always writable, and a
`ZipFile` is writable only when opened with `{ writable: true }`.

Directories are recognized both explicitly (an entry whose name ends in `/`)
and implicitly (any entry name starting with `"<dir>/"`). `readdir()` does
not support `{ recursive: true }`. Because a ZIP member cannot be edited or
read in place - only fully written or fully decompressed - a file opened for
writing only commits its content (as a new archive entry) when the handle is
closed.

Every method has a synchronous counterpart (`openSync()`, `statSync()`,
`readdirSync()`, and so on), backed by the equally complete synchronous
surface [`zlib.ZipBuffer`][]/[`zlib.ZipFile`][] expose. As with those, the
synchronous methods here block the Node.js event loop and further JavaScript
execution until the operation - including any deflate/inflate pass -
completes.

```cjs
const vfs = require('node:vfs');
const zlib = require('node:zlib');
const { readFileSync } = require('node:fs');

async function main() {
const zip = new zlib.ZipBuffer(readFileSync('archive.zip'));
const archiveVfs = vfs.create(new vfs.ZipProvider(zip));

console.log(await archiveVfs.promises.readdir('/'));
await archiveVfs.promises.writeFile('/new.txt', 'hello');
}
main();
```

### `new ZipProvider(source)`

<!-- YAML
added: REPLACEME
-->

* `source` {zlib.ZipBuffer|zlib.ZipFile} An already-open archive.

## Implementation details

### `Stats` objects
Expand All @@ -316,10 +439,17 @@ fields use synthetic but stable values:
* `blocks` is `Math.ceil(size / 512)`.
* Times default to the moment the entry was created/last modified.

[`--require`]: cli.md#-r---require-module
[`--vfs-mount`]: cli.md#--vfs-mountsourcetarget
[`MemoryProvider`]: #class-memoryprovider
[`RealFSProvider`]: #class-realfsprovider
[`VirtualFileSystem`]: #class-virtualfilesystem
[`VirtualProvider`]: #class-virtualprovider
[`ZipProvider`]: #class-zipprovider
[`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
34 changes: 33 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 startup flag, which is 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,34 @@ 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-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). The running program's
own \fBnode:fs\fR calls to paths under \fBtarget\fR then resolve against the
mount, while every other path uses the real file system unchanged.
.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 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
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 +2265,8 @@ one is included in the list below.
.It
\fB--v8-pool-size\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