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`
+
+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 `"
/"`). `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)`
+
+
+
+* `source` {zlib.ZipBuffer|zlib.ZipFile} An already-open archive.
+
## Implementation details
### `Stats` objects
@@ -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
diff --git a/doc/node.1 b/doc/node.1
index 05b80da80a43..41a03151ee1f 100644
--- a/doc/node.1
+++ b/doc/node.1
@@ -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.
@@ -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\fR A directory or an archive file to mount.
+.It
+\fBtarget\fR \fB\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
@@ -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
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index 93498488ce56..852261a4b46b 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -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',
diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js
index 804d588b62ba..5c9ed45eef26 100644
--- a/lib/internal/process/pre_execution.js
+++ b/lib/internal/process/pre_execution.js
@@ -13,6 +13,8 @@ const {
ObjectDefineProperty,
ObjectFreeze,
String,
+ StringPrototypeIndexOf,
+ StringPrototypeSlice,
globalThis,
} = primordials;
@@ -32,6 +34,7 @@ const {
const {
ERR_MISSING_OPTION,
ERR_ACCESS_DENIED,
+ ERR_VFS_INVALID_TARGET,
} = require('internal/errors').codes;
const assert = require('internal/assert');
const {
@@ -175,6 +178,11 @@ function prepareExecution(options) {
initializeModuleLoaders({ shouldSpawnLoaderHookWorker, shouldPreloadModules });
}
+ // Mount any --vfs-mount targets. Runs after -r preload modules, so a
+ // preloaded module can register a custom provider (via node:vfs's
+ // registerProvider()) before a target's provider is chosen.
+ mountVfsTargets();
+
// This has to be done after the user module loader is initialized,
// in case undici is externalized.
setupHttpProxy();
@@ -197,6 +205,50 @@ function setupVmModules() {
}
}
+/**
+ * Mounts each --vfs-mount target as a virtual file system. Each entry is
+ * `` or `=`, where `` is the directory or
+ * archive to mount and `` (optional) is where to mount it, defaulting
+ * to ``'s own resolved path. A provider is chosen for each source by
+ * its contents (see internal/vfs/provider_registry); a source no provider
+ * claims is rejected.
+ */
+function mountVfsTargets() {
+ const entries = getOptionValue('--vfs-mount');
+ if (entries.length === 0) return;
+ emitExperimentalWarning('--vfs-mount');
+
+ const fs = require('fs');
+ const path = require('path');
+ const { selectProvider } = require('internal/vfs/provider_registry');
+ const { VirtualFileSystem } = require('internal/vfs/file_system');
+
+ for (let i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+ const sep = StringPrototypeIndexOf(entry, '=');
+ const source = sep === -1 ? entry : StringPrototypeSlice(entry, 0, sep);
+ const target = sep === -1 ? source : StringPrototypeSlice(entry, sep + 1);
+ const resolvedSource = path.resolve(source);
+ const mountPoint = path.resolve(target);
+
+ let stats;
+ try {
+ stats = fs.statSync(resolvedSource);
+ } catch {
+ throw new ERR_VFS_INVALID_TARGET(resolvedSource);
+ }
+ if (!stats.isDirectory() && !stats.isFile()) {
+ throw new ERR_VFS_INVALID_TARGET(resolvedSource);
+ }
+ const provider = selectProvider(resolvedSource, stats);
+ if (provider === null) {
+ throw new ERR_VFS_INVALID_TARGET(resolvedSource);
+ }
+ const vfs = new VirtualFileSystem(provider, { emitExperimentalWarning: false });
+ vfs.mount(mountPoint);
+ }
+}
+
function setupHttpProxy() {
// This normalized from both --use-env-proxy and NODE_USE_ENV_PROXY settings.
if (!getOptionValue('--use-env-proxy')) {
diff --git a/lib/internal/vfs/provider_registry.js b/lib/internal/vfs/provider_registry.js
new file mode 100644
index 000000000000..a0f0a4b05f6f
--- /dev/null
+++ b/lib/internal/vfs/provider_registry.js
@@ -0,0 +1,142 @@
+'use strict';
+
+// Selection registry for the provider that backs a `--vfs-mount` source (a
+// directory or an archive). This is distinct from the registry of *mounted*
+// VFS instances (in internal/vfs/setup.js); this module answers the earlier
+// question of *which provider* should back a given source.
+//
+// A provider claims a source by inspecting its stats and (for archives) its
+// contents, never its extension, so an archive can carry any name
+// (my-archive.bundle, app.jar, ...). Third-party providers - typically
+// registered from a `-r` or `--import` preload via node:vfs's
+// registerProvider() - are consulted before the built-ins (the directory
+// RealFSProvider and the archive ZipProvider), so they can back, wrap, or vet
+// any mount.
+
+const {
+ ArrayPrototypeUnshift,
+ MathMin,
+ StringPrototypeEndsWith,
+ StringPrototypeToLowerCase,
+} = primordials;
+
+const { Buffer } = require('buffer');
+const {
+ validateFunction,
+ validateObject,
+ validateString,
+} = require('internal/validators');
+
+// An "End Of Central Directory" (EOCD) record: signature PK\x05\x06, a 22-byte
+// fixed part, then an up-to-65535-byte trailing comment.
+const EOCD_SIGNATURE = 0x06054b50; // 'PK\x05\x06', little-endian
+const EOCD_MIN_SIZE = 22;
+const ZIP_MAX_COMMENT = 0xffff;
+
+// Whether `resolvedPath` is a ZIP archive. A ZIP is *defined* by its EOCD
+// record near the end of the file, not by any leading bytes: the archive may
+// be prefixed by arbitrary data (a shebang line, a self-extractor stub, ...),
+// so a prefixed archive need not start with the `PK` local-header signature.
+// Locate the archive the way a ZIP reader does - scan back from EOF for an
+// EOCD signature whose comment-length field lands exactly on EOF - reading
+// only the tail rather than the whole file.
+function looksLikeZip(resolvedPath) {
+ const fs = require('fs');
+ const fd = fs.openSync(resolvedPath, 'r');
+ try {
+ const { size } = fs.fstatSync(fd);
+ if (size < EOCD_MIN_SIZE) return false;
+ const readLen = MathMin(size, EOCD_MIN_SIZE + ZIP_MAX_COMMENT);
+ const buf = Buffer.alloc(readLen);
+ fs.readSync(fd, buf, 0, readLen, size - readLen);
+ for (let i = readLen - EOCD_MIN_SIZE; i >= 0; i--) {
+ if (buf.readUInt32LE(i) === EOCD_SIGNATURE &&
+ i + EOCD_MIN_SIZE + buf.readUInt16LE(i + 20) === readLen) {
+ return true;
+ }
+ }
+ return false;
+ } finally {
+ fs.closeSync(fd);
+ }
+}
+
+// The built-ins are kept last so a registered provider that also claims a
+// source wins - including for a directory, which lets a custom provider wrap
+// or vet the default RealFSProvider (e.g. to record reads). Their require()s
+// are deferred to create() so the machinery stays off the startup path until a
+// mount actually needs it.
+const providers = [
+ {
+ name: 'dir',
+ canHandle(resolvedPath, stats) { return stats.isDirectory(); },
+ create(resolvedPath) {
+ const { RealFSProvider } = require('internal/vfs/providers/real');
+ return new RealFSProvider(resolvedPath);
+ },
+ },
+ {
+ name: 'zip',
+ canHandle(resolvedPath, stats) {
+ if (!stats.isFile()) return false;
+ // Fast path: a `.zip` name is taken at face value, skipping the read. A
+ // ZIP under any other name still gets recognized by sniffing its bytes.
+ if (StringPrototypeEndsWith(StringPrototypeToLowerCase(resolvedPath), '.zip')) {
+ return true;
+ }
+ return looksLikeZip(resolvedPath);
+ },
+ create(resolvedPath) {
+ const { ZipProvider } = require('internal/vfs/providers/ziparchive');
+ const { ZipFile } = require('internal/zip');
+ return new ZipProvider(ZipFile.openSync(resolvedPath));
+ },
+ },
+];
+
+/**
+ * Registers a provider that `--vfs-mount` can select for a source (a directory
+ * or an archive) it recognizes. The newest registration is consulted first,
+ * and all registered providers outrank the built-ins (the directory
+ * RealFSProvider and the archive ZipProvider), so a custom provider can back,
+ * wrap, or vet any mount.
+ * @param {object} entry
+ * @param {string} entry.name A short identifier, used in diagnostics.
+ * @param {(resolvedPath: string, stats: object) => boolean} entry.canHandle
+ * Returns `true` if this provider should back `resolvedPath`. Prefer
+ * inspecting the stats and (for archives) the contents over the file name.
+ * @param {(resolvedPath: string, stats: object) => object} entry.create
+ * Returns the VirtualProvider backing `resolvedPath`.
+ */
+function registerProvider(entry) {
+ validateObject(entry, 'entry');
+ validateString(entry.name, 'entry.name');
+ validateFunction(entry.canHandle, 'entry.canHandle');
+ validateFunction(entry.create, 'entry.create');
+ ArrayPrototypeUnshift(providers, {
+ name: entry.name,
+ canHandle: entry.canHandle,
+ create: entry.create,
+ });
+}
+
+/**
+ * Returns a provider for `resolvedPath`, or `null` if none claims it.
+ * @param {string} resolvedPath
+ * @param {object} stats The `fs.statSync()` result for `resolvedPath`.
+ * @returns {object | null}
+ */
+function selectProvider(resolvedPath, stats) {
+ for (let i = 0; i < providers.length; i++) {
+ const provider = providers[i];
+ if (provider.canHandle(resolvedPath, stats)) {
+ return provider.create(resolvedPath, stats);
+ }
+ }
+ return null;
+}
+
+module.exports = {
+ registerProvider,
+ selectProvider,
+};
diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js
index df9bd00ac1ad..02ceb1d365ef 100644
--- a/lib/internal/vfs/providers/real.js
+++ b/lib/internal/vfs/providers/real.js
@@ -11,7 +11,7 @@ const fs = require('fs');
const path = require('path');
const { VirtualProvider } = require('internal/vfs/provider');
const { VirtualFileHandle } = require('internal/vfs/file_handle');
-const { getValidatedPath } = require('internal/fs/utils');
+const { getValidatedPath, vfsState } = require('internal/fs/utils');
const { setOwnProperty } = require('internal/util');
const {
ERR_METHOD_NOT_IMPLEMENTED,
@@ -24,6 +24,29 @@ const {
const kReadFileUnknownBufferLength = 8192;
+/**
+ * Runs `fn` with the global VFS mount hooks (internal/fs/utils's vfsState)
+ * temporarily disabled. RealFSProvider's own path-based `fs.*` calls must
+ * never go through that hook: if this provider's own rootPath happens to
+ * fall under an active VFS mount - notably its own, when mounted at its own
+ * rootPath, as node --vfs-mount= does - the hook would redirect the
+ * provider's real I/O straight back into itself, recursing forever.
+ * Fd-based calls (RealFileHandle's read/write/fstat/...) are unaffected by
+ * the hook to begin with, since it keys those off virtual fds specifically,
+ * so they don't need this.
+ * @param {Function} fn
+ * @returns {any}
+ */
+function withoutVfsInterception(fn) {
+ const saved = vfsState.handlers;
+ vfsState.handlers = null;
+ try {
+ return fn();
+ } finally {
+ vfsState.handlers = saved;
+ }
+}
+
/**
* A file handle that wraps a real file descriptor.
*/
@@ -175,12 +198,12 @@ class RealFileHandle extends VirtualFileHandle {
writeFileSync(data, options) {
this.#checkClosed('write');
- fs.writeFileSync(this.#realPath, data, options);
+ withoutVfsInterception(() => fs.writeFileSync(this.#realPath, data, options));
}
async writeFile(data, options) {
this.#checkClosed('write');
- return fs.promises.writeFile(this.#realPath, data, options);
+ return withoutVfsInterception(() => fs.promises.writeFile(this.#realPath, data, options));
}
statSync(options) {
@@ -333,112 +356,148 @@ class RealFSProvider extends VirtualProvider {
}
openSync(vfsPath, flags, mode) {
- const realPath = this.#resolvePath(vfsPath);
- const fd = fs.openSync(realPath, flags, mode);
- return new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ const fd = fs.openSync(realPath, flags, mode);
+ return new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath);
+ });
}
async open(vfsPath, flags, mode) {
- const realPath = this.#resolvePath(vfsPath);
- return new Promise((resolve, reject) => {
- fs.open(realPath, flags, mode, (err, fd) => {
- if (err) reject(err);
- else resolve(new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath));
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return new Promise((resolve, reject) => {
+ fs.open(realPath, flags, mode, (err, fd) => {
+ if (err) reject(err);
+ else resolve(new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath));
+ });
});
});
}
statSync(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.statSync(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.statSync(realPath, options);
+ });
}
async stat(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.promises.stat(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.promises.stat(realPath, options);
+ });
}
lstatSync(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath, false);
- return fs.lstatSync(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath, false);
+ return fs.lstatSync(realPath, options);
+ });
}
async lstat(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath, false);
- return fs.promises.lstat(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath, false);
+ return fs.promises.lstat(realPath, options);
+ });
}
readdirSync(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.readdirSync(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.readdirSync(realPath, options);
+ });
}
async readdir(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.promises.readdir(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.promises.readdir(realPath, options);
+ });
}
mkdirSync(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.mkdirSync(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.mkdirSync(realPath, options);
+ });
}
async mkdir(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.promises.mkdir(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.promises.mkdir(realPath, options);
+ });
}
rmdirSync(vfsPath) {
- const realPath = this.#resolvePath(vfsPath);
- fs.rmdirSync(realPath);
+ withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ fs.rmdirSync(realPath);
+ });
}
async rmdir(vfsPath) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.promises.rmdir(realPath);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.promises.rmdir(realPath);
+ });
}
unlinkSync(vfsPath) {
- const realPath = this.#resolvePath(vfsPath);
- fs.unlinkSync(realPath);
+ withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ fs.unlinkSync(realPath);
+ });
}
async unlink(vfsPath) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.promises.unlink(realPath);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.promises.unlink(realPath);
+ });
}
renameSync(oldVfsPath, newVfsPath) {
- const oldRealPath = this.#resolvePath(oldVfsPath);
- const newRealPath = this.#resolvePath(newVfsPath);
- fs.renameSync(oldRealPath, newRealPath);
+ withoutVfsInterception(() => {
+ const oldRealPath = this.#resolvePath(oldVfsPath);
+ const newRealPath = this.#resolvePath(newVfsPath);
+ fs.renameSync(oldRealPath, newRealPath);
+ });
}
async rename(oldVfsPath, newVfsPath) {
- const oldRealPath = this.#resolvePath(oldVfsPath);
- const newRealPath = this.#resolvePath(newVfsPath);
- return fs.promises.rename(oldRealPath, newRealPath);
+ return withoutVfsInterception(() => {
+ const oldRealPath = this.#resolvePath(oldVfsPath);
+ const newRealPath = this.#resolvePath(newVfsPath);
+ return fs.promises.rename(oldRealPath, newRealPath);
+ });
}
readlinkSync(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath, false);
- const target = fs.readlinkSync(realPath, options);
- // Translate absolute targets within rootPath to VFS-relative
- if (path.isAbsolute(target)) {
- const rootWithSep = this.#rootPath + path.sep;
- if (target === this.#rootPath) {
- return '/';
- }
- if (StringPrototypeStartsWith(target, rootWithSep)) {
- return '/' + target.slice(rootWithSep.length).replace(/\\/g, '/');
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath, false);
+ const target = fs.readlinkSync(realPath, options);
+ // Translate absolute targets within rootPath to VFS-relative
+ if (path.isAbsolute(target)) {
+ const rootWithSep = this.#rootPath + path.sep;
+ if (target === this.#rootPath) {
+ return '/';
+ }
+ if (StringPrototypeStartsWith(target, rootWithSep)) {
+ return '/' + target.slice(rootWithSep.length).replace(/\\/g, '/');
+ }
}
- }
- return target;
+ return target;
+ });
}
async readlink(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath, false);
- const target = await fs.promises.readlink(realPath, options);
+ const target = await withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath, false);
+ return fs.promises.readlink(realPath, options);
+ });
// Translate absolute targets within rootPath to VFS-relative
if (path.isAbsolute(target)) {
const rootWithSep = this.#rootPath + path.sep;
@@ -457,15 +516,17 @@ class RealFSProvider extends VirtualProvider {
if (path.isAbsolute(target)) {
throw createEACCES('symlink', vfsPath);
}
- const realPath = this.#resolvePath(vfsPath);
- const resolvedTarget = path.resolve(path.dirname(realPath), target);
- const rootWithSep = this.#rootPath.endsWith(path.sep) ?
- this.#rootPath : this.#rootPath + path.sep;
- if (resolvedTarget !== this.#rootPath &&
- !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
- throw createEACCES('symlink', vfsPath);
- }
- fs.symlinkSync(target, realPath, type);
+ withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ const resolvedTarget = path.resolve(path.dirname(realPath), target);
+ const rootWithSep = this.#rootPath.endsWith(path.sep) ?
+ this.#rootPath : this.#rootPath + path.sep;
+ if (resolvedTarget !== this.#rootPath &&
+ !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
+ throw createEACCES('symlink', vfsPath);
+ }
+ fs.symlinkSync(target, realPath, type);
+ });
}
async symlink(target, vfsPath, type) {
@@ -473,15 +534,17 @@ class RealFSProvider extends VirtualProvider {
if (path.isAbsolute(target)) {
throw createEACCES('symlink', vfsPath);
}
- const realPath = this.#resolvePath(vfsPath);
- const resolvedTarget = path.resolve(path.dirname(realPath), target);
- const rootWithSep = this.#rootPath.endsWith(path.sep) ?
- this.#rootPath : this.#rootPath + path.sep;
- if (resolvedTarget !== this.#rootPath &&
- !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
- throw createEACCES('symlink', vfsPath);
- }
- return fs.promises.symlink(target, realPath, type);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ const resolvedTarget = path.resolve(path.dirname(realPath), target);
+ const rootWithSep = this.#rootPath.endsWith(path.sep) ?
+ this.#rootPath : this.#rootPath + path.sep;
+ if (resolvedTarget !== this.#rootPath &&
+ !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
+ throw createEACCES('symlink', vfsPath);
+ }
+ return fs.promises.symlink(target, realPath, type);
+ });
}
// path.relative handles case-insensitivity on Windows, which matters here
@@ -499,25 +562,33 @@ class RealFSProvider extends VirtualProvider {
}
realpathSync(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- const resolved = fs.realpathSync(realPath, options);
- return this.#resolvedToVfsPath(resolved, vfsPath, 'realpath');
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ const resolved = fs.realpathSync(realPath, options);
+ return this.#resolvedToVfsPath(resolved, vfsPath, 'realpath');
+ });
}
async realpath(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- const resolved = await fs.promises.realpath(realPath, options);
+ const resolved = await withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.promises.realpath(realPath, options);
+ });
return this.#resolvedToVfsPath(resolved, vfsPath, 'realpath');
}
accessSync(vfsPath, mode) {
- const realPath = this.#resolvePath(vfsPath);
- fs.accessSync(realPath, mode);
+ withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ fs.accessSync(realPath, mode);
+ });
}
async access(vfsPath, mode) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.promises.access(realPath, mode);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.promises.access(realPath, mode);
+ });
}
lchmodSync(vfsPath, mode) {
@@ -529,15 +600,19 @@ class RealFSProvider extends VirtualProvider {
}
copyFileSync(srcVfsPath, destVfsPath, mode) {
- const srcRealPath = this.#resolvePath(srcVfsPath);
- const destRealPath = this.#resolvePath(destVfsPath);
- fs.copyFileSync(srcRealPath, destRealPath, mode);
+ withoutVfsInterception(() => {
+ const srcRealPath = this.#resolvePath(srcVfsPath);
+ const destRealPath = this.#resolvePath(destVfsPath);
+ fs.copyFileSync(srcRealPath, destRealPath, mode);
+ });
}
async copyFile(srcVfsPath, destVfsPath, mode) {
- const srcRealPath = this.#resolvePath(srcVfsPath);
- const destRealPath = this.#resolvePath(destVfsPath);
- return fs.promises.copyFile(srcRealPath, destRealPath, mode);
+ return withoutVfsInterception(() => {
+ const srcRealPath = this.#resolvePath(srcVfsPath);
+ const destRealPath = this.#resolvePath(destVfsPath);
+ return fs.promises.copyFile(srcRealPath, destRealPath, mode);
+ });
}
get supportsWatch() {
@@ -545,23 +620,31 @@ class RealFSProvider extends VirtualProvider {
}
watch(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.watch(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.watch(realPath, options);
+ });
}
watchAsync(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.promises.watch(realPath, options);
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.promises.watch(realPath, options);
+ });
}
watchFile(vfsPath, options) {
- const realPath = this.#resolvePath(vfsPath);
- return fs.watchFile(realPath, options, () => {});
+ return withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ return fs.watchFile(realPath, options, () => {});
+ });
}
unwatchFile(vfsPath, listener) {
- const realPath = this.#resolvePath(vfsPath);
- fs.unwatchFile(realPath, listener);
+ withoutVfsInterception(() => {
+ const realPath = this.#resolvePath(vfsPath);
+ fs.unwatchFile(realPath, listener);
+ });
}
}
diff --git a/lib/internal/vfs/providers/ziparchive.js b/lib/internal/vfs/providers/ziparchive.js
new file mode 100644
index 000000000000..f369cdd22f76
--- /dev/null
+++ b/lib/internal/vfs/providers/ziparchive.js
@@ -0,0 +1,580 @@
+'use strict';
+
+const {
+ ArrayPrototypeIndexOf,
+ ArrayPrototypePush,
+ MathMax,
+ MathMin,
+ StringPrototypeIndexOf,
+ StringPrototypeSlice,
+ StringPrototypeStartsWith,
+ SymbolAsyncDispose,
+ SymbolDispose,
+} = primordials;
+
+const { Buffer } = require('buffer');
+const {
+ codes: {
+ ERR_INVALID_ARG_TYPE,
+ ERR_METHOD_NOT_IMPLEMENTED,
+ },
+} = require('internal/errors');
+const { VirtualProvider } = require('internal/vfs/provider');
+const { VirtualFileHandle } = require('internal/vfs/file_handle');
+const {
+ createEEXIST,
+ createEISDIR,
+ createENOENT,
+ createENOTDIR,
+ createENOTEMPTY,
+ createEROFS,
+} = require('internal/vfs/errors');
+const { createFileStats, createDirectoryStats } = require('internal/vfs/stats');
+const { Dirent } = require('internal/fs/utils');
+const {
+ fs: {
+ O_APPEND,
+ O_CREAT,
+ O_EXCL,
+ O_RDWR,
+ O_TRUNC,
+ O_WRONLY,
+ UV_DIRENT_DIR,
+ UV_DIRENT_FILE,
+ },
+} = internalBinding('constants');
+const { ZipBuffer, ZipFile } = require('internal/zip');
+
+const EMPTY_BUFFER = Buffer.alloc(0);
+
+function normalize(vfsPath) {
+ return StringPrototypeStartsWith(vfsPath, '/') ? StringPrototypeSlice(vfsPath, 1) : vfsPath;
+}
+
+// Converts numeric open flags (e.g. `fs.constants.O_RDWR`) to the flag strings
+// the helpers below understand, so a caller passing `node:fs`-style numeric
+// flags through the VFS is handled the same way `fs` would. Strings pass
+// through unchanged; anything else falls back to 'r'.
+function normalizeFlags(flags) {
+ if (typeof flags === 'string') return flags;
+ if (typeof flags !== 'number') return 'r';
+ const rdwr = (flags & O_RDWR) !== 0;
+ const append = (flags & O_APPEND) !== 0;
+ const excl = (flags & O_EXCL) !== 0;
+ const write = (flags & O_WRONLY) !== 0 || (flags & O_CREAT) !== 0 || (flags & O_TRUNC) !== 0;
+ if (append) return 'a' + (excl ? 'x' : '') + (rdwr ? '+' : '');
+ if (write) return 'w' + (excl ? 'x' : '') + (rdwr ? '+' : '');
+ if (rdwr) return 'r+';
+ return 'r';
+}
+
+function isCurrentPosition(position) {
+ return position === null || position === undefined || position === -1;
+}
+
+function isWriteTruncate(flags) {
+ return flags === 'w' || flags === 'w+' || flags === 'wx' || flags === 'wx+';
+}
+
+function isAppend(flags) {
+ return flags === 'a' || flags === 'a+' || flags === 'ax' || flags === 'ax+';
+}
+
+function isReadableFlag(flags) {
+ return flags !== 'w' && flags !== 'a' && flags !== 'wx' && flags !== 'ax';
+}
+
+function isWritableFlag(flags) {
+ return flags !== 'r';
+}
+
+/**
+ * The `options.method` value that reproduces `method` (a `zipEntry.method`
+ * raw compression method number) on `add()`/`addSync()`, so `rename()`
+ * doesn't silently recompress an entry with a different method than the one
+ * it already had (e.g. turning a zstd-compressed entry into a stored one).
+ * @param {number} method
+ * @returns {'store' | 'zstd' | 'deflate'}
+ */
+function methodOption(method) {
+ if (method === 0) return 'store';
+ if (method === 93) return 'zstd';
+ return 'deflate';
+}
+
+/**
+ * A file handle over one ZIP entry. ZIP members can't be edited in place
+ * (they're a single compressed blob), so writes accumulate in memory and are
+ * only committed - as a brand-new entry - when the handle is closed. Since
+ * this is all in-memory buffer manipulation with no real I/O, every method
+ * and its `*Sync` counterpart share one private implementation.
+ */
+class ZipFileHandle extends VirtualFileHandle {
+ #source;
+ #name;
+ #buffer;
+ #size;
+ #dirty = false;
+
+ /**
+ * @param {string} path
+ * @param {string} flags
+ * @param {number} mode
+ * @param {ZipBuffer | ZipFile} source
+ * @param {string} name The archive-relative entry name
+ * @param {Buffer} initial The entry's current decompressed content, or an
+ * empty buffer for a new/truncated file
+ */
+ constructor(path, flags, mode, source, name, initial) {
+ super(path, flags, mode);
+ this.#source = source;
+ this.#name = name;
+ this.#buffer = initial;
+ this.#size = initial.length;
+ if (isAppend(flags)) this.position = this.#size;
+ }
+
+ #checkReadable() {
+ if (!isReadableFlag(this.flags)) throw createEISDIR('read', this.path);
+ }
+ #checkWritable() {
+ if (!isWritableFlag(this.flags)) throw createEISDIR('write', this.path);
+ }
+ #ensureCapacity(size) {
+ if (size <= this.#buffer.length) return;
+ const capacity = MathMax(size, this.#buffer.length * 2);
+ const grown = Buffer.alloc(capacity);
+ this.#buffer.copy(grown, 0, 0, this.#size);
+ this.#buffer = grown;
+ }
+
+ #doRead(buffer, offset, length, position) {
+ this.#checkReadable();
+ const useCurrent = isCurrentPosition(position);
+ const pos = useCurrent ? this.position : position;
+ const available = MathMax(0, this.#size - pos);
+ const bytesRead = MathMin(length, available);
+ if (bytesRead > 0) this.#buffer.copy(buffer, offset, pos, pos + bytesRead);
+ if (useCurrent) this.position = pos + bytesRead;
+ return { __proto__: null, bytesRead, buffer };
+ }
+ async read(buffer, offset, length, position) {
+ return this.#doRead(buffer, offset, length, position);
+ }
+ readSync(buffer, offset, length, position) {
+ return this.#doRead(buffer, offset, length, position);
+ }
+
+ #doWrite(buffer, offset, length, position) {
+ this.#checkWritable();
+ const useCurrent = isCurrentPosition(position);
+ const pos = isAppend(this.flags) ? this.#size : (useCurrent ? this.position : position);
+ this.#ensureCapacity(pos + length);
+ buffer.copy(this.#buffer, pos, offset, offset + length);
+ if (pos + length > this.#size) this.#size = pos + length;
+ this.#dirty = true;
+ if (useCurrent) this.position = pos + length;
+ return { __proto__: null, bytesWritten: length, buffer };
+ }
+ async write(buffer, offset, length, position) {
+ return this.#doWrite(buffer, offset, length, position);
+ }
+ writeSync(buffer, offset, length, position) {
+ return this.#doWrite(buffer, offset, length, position);
+ }
+
+ #doReadFile(options) {
+ this.#checkReadable();
+ const encoding = typeof options === 'string' ? options : options?.encoding;
+ const content = this.#buffer.subarray(0, this.#size);
+ return encoding && encoding !== 'buffer' ? content.toString(encoding) : Buffer.from(content);
+ }
+ async readFile(options) {
+ return this.#doReadFile(options);
+ }
+ readFileSync(options) {
+ return this.#doReadFile(options);
+ }
+
+ // Replaces content, except in append mode ('a'/'a+'/'ax'/'ax+'), where it
+ // appends to the existing content instead - matching MemoryFileHandle and
+ // what makes `appendFile()`/`appendFileSync()` (built on this, by
+ // VirtualProvider's defaults) actually append.
+ #doWriteFile(data, options) {
+ this.#checkWritable();
+ const content = typeof data === 'string' ? Buffer.from(data, options?.encoding) : Buffer.from(data);
+ if (isAppend(this.flags)) {
+ this.#ensureCapacity(this.#size + content.length);
+ content.copy(this.#buffer, this.#size);
+ this.#size += content.length;
+ } else {
+ this.#buffer = content;
+ this.#size = content.length;
+ }
+ this.#dirty = true;
+ }
+ async writeFile(data, options) {
+ this.#doWriteFile(data, options);
+ }
+ writeFileSync(data, options) {
+ this.#doWriteFile(data, options);
+ }
+
+ #doStat() {
+ return createFileStats(this.#size, { mode: this.mode });
+ }
+ async stat(options) {
+ return this.#doStat();
+ }
+ statSync(options) {
+ return this.#doStat();
+ }
+
+ #doTruncate(len) {
+ this.#checkWritable();
+ this.#ensureCapacity(len);
+ this.#size = len;
+ this.#dirty = true;
+ }
+ async truncate(len = 0) {
+ this.#doTruncate(len);
+ }
+ truncateSync(len = 0) {
+ this.#doTruncate(len);
+ }
+
+ async close() {
+ if (this.#dirty && isWritableFlag(this.flags)) {
+ await this.#source.add(this.#name, this.#buffer.subarray(0, this.#size), { mode: this.mode });
+ }
+ await super.close();
+ }
+ closeSync() {
+ if (this.#dirty && isWritableFlag(this.flags)) {
+ this.#source.addSync(this.#name, this.#buffer.subarray(0, this.#size), { mode: this.mode });
+ }
+ super.closeSync();
+ }
+}
+
+/**
+ * A `node:vfs` provider backed by a ZIP archive: either a [`ZipBuffer`][] (in
+ * memory) or a [`ZipFile`][] (on disk). Read-only unless the underlying
+ * archive is writable (a `ZipBuffer`, or a `ZipFile` opened with
+ * `{ writable: true }`). Every method has a synchronous counterpart, backed
+ * by the equally complete synchronous surface `ZipBuffer`/`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.
+ */
+class ZipProvider extends VirtualProvider {
+ #source;
+
+ /**
+ * @param {ZipBuffer | ZipFile} source
+ */
+ constructor(source) {
+ super();
+ if (!(source instanceof ZipBuffer) && !(source instanceof ZipFile)) {
+ throw new ERR_INVALID_ARG_TYPE('source', ['ZipBuffer', 'ZipFile'], source);
+ }
+ this.#source = source;
+ }
+
+ get readonly() { return !this.#source.writable; }
+
+ /**
+ * @param {string} name
+ * @returns {Promise}
+ */
+ async #getEntry(name) {
+ return this.#source.has(name) ? this.#source.get(name) : null;
+ }
+ /**
+ * @param {string} name
+ * @returns {import('internal/zip').ZipEntry | null}
+ */
+ #getEntrySync(name) {
+ if (!this.#source.has(name)) return null;
+ // `ZipBuffer.prototype.get` is already synchronous (it has no `getSync`
+ // of its own); `ZipFile.prototype.get` is asynchronous, so its `getSync`
+ // is used instead when present.
+ return typeof this.#source.getSync === 'function' ?
+ this.#source.getSync(name) : this.#source.get(name);
+ }
+ /**
+ * @param {string} name
+ * @returns {boolean}
+ */
+ #deleteEntrySync(name) {
+ // Same reasoning as `#getEntrySync`: `ZipBuffer.prototype.delete` is
+ // already synchronous; `ZipFile.prototype.delete` is not, so its
+ // `deleteSync` is used instead when present.
+ return typeof this.#source.deleteSync === 'function' ?
+ this.#source.deleteSync(name) : this.#source.delete(name);
+ }
+
+ /**
+ * Whether `name` (no trailing slash) is a directory: either explicitly
+ * (a `"name/"` entry) or implicitly (some entry starts with `"name/"`).
+ * @param {string} name
+ * @returns {boolean}
+ */
+ #isDirectory(name) {
+ const prefix = `${name}/`;
+ if (this.#source.has(prefix)) return true;
+ for (const key of this.#source.keys()) {
+ if (StringPrototypeStartsWith(key, prefix)) return true;
+ }
+ return false;
+ }
+
+ async open(path, flags, mode) {
+ flags = normalizeFlags(flags);
+ const name = normalize(path);
+ const fileEntry = await this.#getEntry(name);
+ if (fileEntry === null && this.#isDirectory(name)) {
+ throw createEISDIR('open', path);
+ }
+ const exists = fileEntry !== null;
+ if (isWritableFlag(flags) && this.readonly) {
+ throw createEROFS('open', path);
+ }
+ if ((flags === 'wx' || flags === 'wx+' || flags === 'ax' || flags === 'ax+') && exists) {
+ throw createEEXIST('open', path);
+ }
+ if (!exists && (flags === 'r' || flags === 'r+')) {
+ throw createENOENT('open', path);
+ }
+ let initial = EMPTY_BUFFER;
+ if (exists && !isWriteTruncate(flags)) {
+ initial = await fileEntry.content();
+ }
+ return new ZipFileHandle(path, flags, mode, this.#source, name, initial);
+ }
+ openSync(path, flags, mode) {
+ flags = normalizeFlags(flags);
+ const name = normalize(path);
+ const fileEntry = this.#getEntrySync(name);
+ if (fileEntry === null && this.#isDirectory(name)) {
+ throw createEISDIR('open', path);
+ }
+ const exists = fileEntry !== null;
+ if (isWritableFlag(flags) && this.readonly) {
+ throw createEROFS('open', path);
+ }
+ if ((flags === 'wx' || flags === 'wx+' || flags === 'ax' || flags === 'ax+') && exists) {
+ throw createEEXIST('open', path);
+ }
+ if (!exists && (flags === 'r' || flags === 'r+')) {
+ throw createENOENT('open', path);
+ }
+ let initial = EMPTY_BUFFER;
+ if (exists && !isWriteTruncate(flags)) {
+ initial = fileEntry.contentSync();
+ }
+ return new ZipFileHandle(path, flags, mode, this.#source, name, initial);
+ }
+
+ async stat(path, options) {
+ const name = normalize(path);
+ if (name === '') return createDirectoryStats({ mode: 0o755 });
+ const entry = await this.#getEntry(name) ?? await this.#getEntry(`${name}/`);
+ if (entry !== null) {
+ return entry.isDirectory ?
+ createDirectoryStats({ mode: entry.mode || 0o755, mtimeMs: entry.modified.getTime() }) :
+ createFileStats(entry.size, { mode: entry.mode || 0o644, mtimeMs: entry.modified.getTime() });
+ }
+ if (this.#isDirectory(name)) return createDirectoryStats({ mode: 0o755 });
+ throw createENOENT('stat', path);
+ }
+ statSync(path, options) {
+ const name = normalize(path);
+ if (name === '') return createDirectoryStats({ mode: 0o755 });
+ const entry = this.#getEntrySync(name) ?? this.#getEntrySync(`${name}/`);
+ if (entry !== null) {
+ return entry.isDirectory ?
+ createDirectoryStats({ mode: entry.mode || 0o755, mtimeMs: entry.modified.getTime() }) :
+ createFileStats(entry.size, { mode: entry.mode || 0o644, mtimeMs: entry.modified.getTime() });
+ }
+ if (this.#isDirectory(name)) return createDirectoryStats({ mode: 0o755 });
+ throw createENOENT('stat', path);
+ }
+
+ #readdirEntries(path, name, options, stats) {
+ if (!stats.isDirectory()) throw createENOTDIR('scandir', path);
+ const prefix = name === '' ? '' : `${name}/`;
+ const withFileTypes = options?.withFileTypes === true;
+ const names = [];
+ const isDir = [];
+ for (const key of this.#source.keys()) {
+ if (!StringPrototypeStartsWith(key, prefix)) continue;
+ const rest = StringPrototypeSlice(key, prefix.length);
+ if (rest === '') continue; // The directory's own explicit entry
+ const slash = StringPrototypeIndexOf(rest, '/');
+ const childName = slash === -1 ? rest : StringPrototypeSlice(rest, 0, slash);
+ const childIsDir = slash !== -1;
+ const existingIndex = ArrayPrototypeIndexOf(names, childName);
+ if (existingIndex !== -1) {
+ if (childIsDir) isDir[existingIndex] = true;
+ continue;
+ }
+ ArrayPrototypePush(names, childName);
+ ArrayPrototypePush(isDir, childIsDir);
+ }
+ const result = [];
+ for (let i = 0; i < names.length; i++) {
+ if (withFileTypes) {
+ ArrayPrototypePush(result, new Dirent(names[i], isDir[i] ? UV_DIRENT_DIR : UV_DIRENT_FILE, name));
+ } else {
+ ArrayPrototypePush(result, names[i]);
+ }
+ }
+ return result;
+ }
+ async readdir(path, options) {
+ if (options?.recursive) {
+ throw new ERR_METHOD_NOT_IMPLEMENTED('readdir with { recursive: true } on a ZipProvider');
+ }
+ const name = normalize(path);
+ return this.#readdirEntries(path, name, options, await this.stat(path));
+ }
+ readdirSync(path, options) {
+ if (options?.recursive) {
+ throw new ERR_METHOD_NOT_IMPLEMENTED('readdirSync with { recursive: true } on a ZipProvider');
+ }
+ const name = normalize(path);
+ return this.#readdirEntries(path, name, options, this.statSync(path));
+ }
+
+ async mkdir(path, options) {
+ if (this.readonly) throw createEROFS('mkdir', path);
+ const name = normalize(path);
+ if (await this.exists(path)) {
+ // `{ recursive: true }` only tolerates an existing *directory*; an
+ // existing file (or any non-directory) still collides with EEXIST.
+ if (options?.recursive && this.#isDirectory(name)) return undefined;
+ throw createEEXIST('mkdir', path);
+ }
+ await this.#source.add(`${name}/`, EMPTY_BUFFER, { mode: options?.mode });
+ return undefined;
+ }
+ mkdirSync(path, options) {
+ if (this.readonly) throw createEROFS('mkdir', path);
+ const name = normalize(path);
+ if (this.existsSync(path)) {
+ // `{ recursive: true }` only tolerates an existing *directory*; an
+ // existing file (or any non-directory) still collides with EEXIST.
+ if (options?.recursive && this.#isDirectory(name)) return undefined;
+ throw createEEXIST('mkdir', path);
+ }
+ this.#source.addSync(`${name}/`, EMPTY_BUFFER, { mode: options?.mode });
+ return undefined;
+ }
+
+ async rmdir(path) {
+ if (this.readonly) throw createEROFS('rmdir', path);
+ const name = normalize(path);
+ const stats = await this.stat(path);
+ if (!stats.isDirectory()) throw createENOTDIR('rmdir', path);
+ const prefix = `${name}/`;
+ for (const key of this.#source.keys()) {
+ if (key !== prefix && StringPrototypeStartsWith(key, prefix)) {
+ throw createENOTEMPTY('rmdir', path);
+ }
+ }
+ if (!this.#source.has(prefix)) {
+ // An implicit-only directory can never be empty (something has to be
+ // under it for it to exist at all), so getting here means `path` is
+ // not a directory this provider can remove.
+ throw createENOENT('rmdir', path);
+ }
+ await this.#source.delete(prefix);
+ }
+ rmdirSync(path) {
+ if (this.readonly) throw createEROFS('rmdir', path);
+ const name = normalize(path);
+ const stats = this.statSync(path);
+ if (!stats.isDirectory()) throw createENOTDIR('rmdir', path);
+ const prefix = `${name}/`;
+ for (const key of this.#source.keys()) {
+ if (key !== prefix && StringPrototypeStartsWith(key, prefix)) {
+ throw createENOTEMPTY('rmdir', path);
+ }
+ }
+ if (!this.#source.has(prefix)) {
+ throw createENOENT('rmdir', path);
+ }
+ this.#deleteEntrySync(prefix);
+ }
+
+ async unlink(path) {
+ if (this.readonly) throw createEROFS('unlink', path);
+ const name = normalize(path);
+ if (!this.#source.has(name)) {
+ throw this.#isDirectory(name) ? createEISDIR('unlink', path) : createENOENT('unlink', path);
+ }
+ await this.#source.delete(name);
+ }
+ unlinkSync(path) {
+ if (this.readonly) throw createEROFS('unlink', path);
+ const name = normalize(path);
+ if (!this.#source.has(name)) {
+ throw this.#isDirectory(name) ? createEISDIR('unlink', path) : createENOENT('unlink', path);
+ }
+ this.#deleteEntrySync(name);
+ }
+
+ async rename(oldPath, newPath) {
+ if (this.readonly) throw createEROFS('rename', oldPath);
+ const oldName = normalize(oldPath);
+ const newName = normalize(newPath);
+ const entry = await this.#getEntry(oldName);
+ if (entry === null) throw createENOENT('rename', oldPath);
+ const content = await entry.content();
+ await this.#source.add(newName, content, {
+ mode: entry.mode || undefined,
+ modified: entry.modified,
+ method: methodOption(entry.method),
+ });
+ await this.#source.delete(oldName);
+ }
+ renameSync(oldPath, newPath) {
+ if (this.readonly) throw createEROFS('rename', oldPath);
+ const oldName = normalize(oldPath);
+ const newName = normalize(newPath);
+ const entry = this.#getEntrySync(oldName);
+ if (entry === null) throw createENOENT('rename', oldPath);
+ const content = entry.contentSync();
+ this.#source.addSync(newName, content, {
+ mode: entry.mode || undefined,
+ modified: entry.modified,
+ method: methodOption(entry.method),
+ });
+ this.#deleteEntrySync(oldName);
+ }
+
+ /**
+ * Closes the backing archive. A `ZipFile` releases its file descriptor; a
+ * `ZipBuffer` is purely in-memory and has nothing to close, so this is a
+ * no-op for it. Aliased as `Symbol.asyncDispose` so a provider can be used
+ * with `await using`.
+ */
+ async close() {
+ if (typeof this.#source.close === 'function') await this.#source.close();
+ }
+ /**
+ * Synchronous counterpart of `close()`, aliased as `Symbol.dispose` for
+ * `using`.
+ */
+ closeSync() {
+ if (typeof this.#source.closeSync === 'function') this.#source.closeSync();
+ }
+}
+
+ZipProvider.prototype[SymbolAsyncDispose] = ZipProvider.prototype.close;
+ZipProvider.prototype[SymbolDispose] = ZipProvider.prototype.closeSync;
+
+module.exports = {
+ ZipProvider,
+};
diff --git a/lib/internal/worker.js b/lib/internal/worker.js
index 3afd2180d705..216e209d7322 100644
--- a/lib/internal/worker.js
+++ b/lib/internal/worker.js
@@ -5,6 +5,9 @@ const {
ArrayPrototypeForEach,
ArrayPrototypeMap,
ArrayPrototypePush,
+ ArrayPrototypePushApply,
+ ArrayPrototypeSlice,
+ ArrayPrototypeSome,
AtomicsAdd,
Float64Array,
FunctionPrototypeBind,
@@ -19,6 +22,7 @@ const {
SafeArrayIterator,
SafeMap,
String,
+ StringPrototypeStartsWith,
StringPrototypeTrim,
Symbol,
SymbolAsyncDispose,
@@ -30,6 +34,7 @@ const {
const EventEmitter = require('events');
const assert = require('internal/assert');
const path = require('path');
+const { getOptionValue } = require('internal/options');
const {
internalEventLoopUtilization,
} = require('internal/perf/event_loop_utilization');
@@ -203,6 +208,37 @@ class HeapProfileHandle {
}
}
+/**
+ * A Worker constructed without an explicit `execArgv` already inherits the
+ * parent's whole per-isolate options (including --vfs-mount) via
+ * the native Clone()-on-no-explicit-execArgv path. But one constructed with
+ * an explicit `execArgv` gets a fresh, from-scratch options parse instead -
+ * so without this, code running under a VFS mount could spawn an "escaped"
+ * worker simply by passing its own execArgv. Force the active flags along
+ * unless the caller already specified their own --vfs-mount.
+ * @param {Array} execArgv
+ * @returns {Array}
+ */
+function withInheritedVfsFlags(execArgv) {
+ const toInject = [];
+
+ // If the caller supplied any --vfs-mount of their own, respect it wholesale
+ // rather than merging; otherwise carry the parent's mounts along.
+ const hasOwnMounts = ArrayPrototypeSome(
+ execArgv, (arg) => StringPrototypeStartsWith(arg, '--vfs-mount'));
+ if (!hasOwnMounts) {
+ const vfsMounts = getOptionValue('--vfs-mount');
+ for (let i = 0; i < vfsMounts.length; i++) {
+ ArrayPrototypePush(toInject, `--vfs-mount=${vfsMounts[i]}`);
+ }
+ }
+
+ if (toInject.length === 0) return execArgv;
+ const result = ArrayPrototypeSlice(execArgv);
+ ArrayPrototypePushApply(result, toInject);
+ return result;
+}
+
class Worker extends EventEmitter {
constructor(filename, options = kEmptyObject) {
throwIfBuildingSnapshot('Creating workers');
@@ -214,8 +250,11 @@ class Worker extends EventEmitter {
options,
`isInternal: ${isInternal}`,
);
- if (options.execArgv)
- validateArray(options.execArgv, 'options.execArgv');
+ let execArgv = options.execArgv;
+ if (execArgv) {
+ validateArray(execArgv, 'options.execArgv');
+ execArgv = withInheritedVfsFlags(execArgv);
+ }
let argv;
if (options.argv) {
@@ -287,7 +326,7 @@ class Worker extends EventEmitter {
// Set up the C++ handle for the worker, as well as some internal wiring.
this[kHandle] = new WorkerImpl(url,
env === process.env ? null : env,
- options.execArgv,
+ execArgv,
parseResourceLimits(options.resourceLimits),
!!(options.trackUnmanagedFds ?? true),
isInternal,
diff --git a/lib/vfs.js b/lib/vfs.js
index 0d12229aca72..47e48b3d62c3 100644
--- a/lib/vfs.js
+++ b/lib/vfs.js
@@ -8,6 +8,8 @@ const { VirtualFileSystem } = require('internal/vfs/file_system');
const { VirtualProvider } = require('internal/vfs/provider');
const { MemoryProvider } = require('internal/vfs/providers/memory');
const { RealFSProvider } = require('internal/vfs/providers/real');
+const { ZipProvider } = require('internal/vfs/providers/ziparchive');
+const { registerProvider } = require('internal/vfs/provider_registry');
/**
* Creates a new VirtualFileSystem instance.
@@ -30,8 +32,10 @@ function create(provider, options) {
module.exports = {
create,
+ registerProvider,
VirtualFileSystem,
VirtualProvider,
MemoryProvider,
RealFSProvider,
+ ZipProvider,
};
diff --git a/src/node_options.cc b/src/node_options.cc
index 383522863bb8..66ffd968d870 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -215,6 +215,10 @@ void EnvironmentOptions::CheckOptions(std::vector* errors,
}
#endif // HAVE_OPENSSL
+ if (!experimental_vfs && !vfs_mounts.empty()) {
+ errors->push_back("--vfs-mount requires --experimental-vfs");
+ }
+
if (heap_snapshot_near_heap_limit < 0) {
errors->push_back("--heapsnapshot-near-heap-limit must not be negative");
}
@@ -623,6 +627,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
"experimental node:vfs module",
&EnvironmentOptions::experimental_vfs,
kAllowedInEnvvar);
+ AddOption("--vfs-mount",
+ "mount a directory or archive as a virtual file system at "
+ ", or at when given as = "
+ "(option can be repeated; requires --experimental-vfs)",
+ &EnvironmentOptions::vfs_mounts,
+ kAllowedInEnvvar);
AddOption("--experimental-quic",
#ifndef OPENSSL_NO_QUIC
"experimental QUIC support",
diff --git a/src/node_options.h b/src/node_options.h
index fc758f2444aa..10943da3d017 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -133,6 +133,7 @@ class EnvironmentOptions : public Options {
bool experimental_sqlite = HAVE_SQLITE;
bool experimental_stream_iter = EXPERIMENTALS_DEFAULT_VALUE;
bool experimental_vfs = EXPERIMENTALS_DEFAULT_VALUE;
+ std::vector vfs_mounts;
bool webstorage = HAVE_SQLITE;
bool experimental_dtls = EXPERIMENTALS_DEFAULT_VALUE;
bool experimental_quic = EXPERIMENTALS_DEFAULT_VALUE;
diff --git a/test/parallel/test-vfs-mount-flag.js b/test/parallel/test-vfs-mount-flag.js
new file mode 100644
index 000000000000..b4a8e77230a2
--- /dev/null
+++ b/test/parallel/test-vfs-mount-flag.js
@@ -0,0 +1,104 @@
+'use strict';
+
+// Exercises --vfs-mount end to end: a normal entry script runs from the real
+// file system but can read from one or more mounted VFSes - at the source's
+// own path, or at an explicit `source=target` mount point - and a source no
+// provider claims is rejected.
+
+const common = require('../common');
+const tmpdir = require('../common/tmpdir');
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+const { spawnSync } = require('child_process');
+
+tmpdir.refresh();
+let id = 0;
+function fixture(name) { return path.join(tmpdir.path, `${id++}-${name}`); }
+
+async function writeZip(files, dest) {
+ const entries = [];
+ for (const { 0: name, 1: content } of Object.entries(files)) {
+ entries.push(await zlib.ZipEntry.create(name, Buffer.from(content)));
+ }
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk);
+ fs.writeFileSync(dest, Buffer.concat(chunks));
+}
+
+function run(args) {
+ return spawnSync(process.execPath, ['--experimental-vfs', ...args], { encoding: 'utf8' });
+}
+
+(async () => {
+ // -- A plain directory mounts at its own path (RealFSProvider) --------------
+ // Reads under the mount must reach the real files, not loop back through the
+ // mount hook into the provider's own I/O.
+ {
+ const dir = fixture('data-dir');
+ fs.mkdirSync(dir);
+ fs.writeFileSync(path.join(dir, 'note.txt'), 'hello from the real dir');
+ const script = fixture('read-dir.js');
+ fs.writeFileSync(script,
+ `const fs = require('fs');\n` +
+ `console.log(fs.readFileSync(${JSON.stringify(dir)} + '/note.txt', 'utf8'));\n`);
+ const res = run([`--vfs-mount=${dir}`, script]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /hello from the real dir/);
+ }
+
+ // -- Mount-only: a normal entry script reads its own data from the mount ----
+ {
+ const dataZip = fixture('data.zip');
+ await writeZip({ 'greeting.txt': 'hi from the archive' }, dataZip);
+ const script = fixture('app.js');
+ fs.writeFileSync(script,
+ `const fs = require('fs');\n` +
+ `console.log(fs.readFileSync(${JSON.stringify(dataZip)} + '/greeting.txt', 'utf8'));\n`);
+ // The script itself is the entry; the archive is mounted as data.
+ const res = run([`--vfs-mount=${dataZip}`, script]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /hi from the archive/);
+ }
+
+ // -- Explicit, virtual mount point (source=target) -------------------------
+ {
+ const dataZip = fixture('data2.zip');
+ await writeZip({ 'x.txt': 'mounted elsewhere' }, dataZip);
+ const mountPoint = path.join(tmpdir.path, 'mnt-elsewhere'); // need not exist
+ const script = fixture('app2.js');
+ fs.writeFileSync(script,
+ `const fs = require('fs');\n` +
+ `console.log(fs.readFileSync(${JSON.stringify(mountPoint)} + '/x.txt', 'utf8'));\n`);
+ const res = run([`--vfs-mount=${dataZip}=${mountPoint}`, script]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /mounted elsewhere/);
+ }
+
+ // -- Multiple --vfs-mount, both active at once -----------------------------
+ {
+ const zipA = fixture('a.zip');
+ const zipB = fixture('b.zip');
+ await writeZip({ 'a.txt': 'from A' }, zipA);
+ await writeZip({ 'b.txt': 'from B' }, zipB);
+ const script = fixture('app3.js');
+ fs.writeFileSync(script,
+ `const fs = require('fs');\n` +
+ `console.log(fs.readFileSync(${JSON.stringify(zipA)} + '/a.txt', 'utf8'));\n` +
+ `console.log(fs.readFileSync(${JSON.stringify(zipB)} + '/b.txt', 'utf8'));\n`);
+ const res = run([`--vfs-mount=${zipA}`, `--vfs-mount=${zipB}`, script]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /from A/);
+ assert.match(res.stdout, /from B/);
+ }
+
+ // -- a source no provider claims is rejected -------------------------------
+ {
+ const bogus = fixture('mystery.bin');
+ fs.writeFileSync(bogus, Buffer.from('not an archive of any known kind'));
+ const res = run([`--vfs-mount=${bogus}`]);
+ assert.notStrictEqual(res.status, 0);
+ assert.match(res.stderr, /ERR_VFS_INVALID_TARGET/);
+ }
+})().then(common.mustCall());
diff --git a/test/parallel/test-vfs-mount-provider.js b/test/parallel/test-vfs-mount-provider.js
new file mode 100644
index 000000000000..d147f0e147fe
--- /dev/null
+++ b/test/parallel/test-vfs-mount-provider.js
@@ -0,0 +1,147 @@
+'use strict';
+
+// Exercises custom --vfs-mount providers registered via node:vfs's
+// registerProvider() from a `-r` preload. A plain entry script reads a file
+// from the mount to observe which provider backed it:
+// 1. a custom provider backs a --vfs-mount of its own file format,
+// 2. the built-in ZIP provider recognizes a ZIP by its bytes, not its name,
+// 3. a custom provider is tried before the built-ins (precedence), and
+// 4. a custom provider can claim a directory, overriding RealFSProvider.
+
+const common = require('../common');
+const tmpdir = require('../common/tmpdir');
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+const { spawnSync } = require('child_process');
+
+tmpdir.refresh();
+let id = 0;
+function fixture(name) { return path.join(tmpdir.path, `${id++}-${name}`); }
+
+async function zipFromTree(files, dest) {
+ const entries = [];
+ for (const { 0: name, 1: content } of Object.entries(files)) {
+ entries.push(await zlib.ZipEntry.create(name, Buffer.from(content)));
+ }
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk);
+ fs.writeFileSync(dest, Buffer.concat(chunks));
+}
+
+// A -r preload registering a provider for a "CUSTOMFMT" file format that
+// serves a data.txt made from the bytes after the magic.
+const customProvider = fixture('custom-provider.js');
+fs.writeFileSync(customProvider, `
+'use strict';
+const fs = require('fs');
+const vfs = require('node:vfs');
+const MAGIC = Buffer.from('CUSTOMFMT');
+vfs.registerProvider({
+ name: 'customfmt',
+ canHandle(p, stats) {
+ if (!stats.isFile()) return false;
+ const fd = fs.openSync(p, 'r');
+ try {
+ const buf = Buffer.alloc(MAGIC.length);
+ fs.readSync(fd, buf, 0, MAGIC.length, 0);
+ return buf.equals(MAGIC);
+ } finally { fs.closeSync(fd); }
+ },
+ create(p) {
+ const body = fs.readFileSync(p).subarray(MAGIC.length).toString('utf8');
+ const provider = new vfs.MemoryProvider();
+ provider.writeFileSync('/data.txt', body);
+ return provider;
+ },
+});
+`);
+
+// A -r preload whose provider claims every source (proves precedence).
+const greedyProvider = fixture('greedy-provider.js');
+fs.writeFileSync(greedyProvider, `
+'use strict';
+const vfs = require('node:vfs');
+vfs.registerProvider({
+ name: 'greedy',
+ canHandle() { return true; },
+ create() {
+ const provider = new vfs.MemoryProvider();
+ provider.writeFileSync('/data.txt', 'greedy provider won');
+ return provider;
+ },
+});
+`);
+
+// A -r preload whose provider claims directories (overrides RealFSProvider).
+const dirProvider = fixture('dir-provider.js');
+fs.writeFileSync(dirProvider, `
+'use strict';
+const vfs = require('node:vfs');
+vfs.registerProvider({
+ name: 'dir-override',
+ canHandle(p, stats) { return stats.isDirectory(); },
+ create() {
+ const provider = new vfs.MemoryProvider();
+ provider.writeFileSync('/data.txt', 'custom dir provider won');
+ return provider;
+ },
+});
+`);
+
+// Writes a plain entry script that prints `/data.txt`.
+function consumerFor(mountPoint) {
+ const script = fixture('consumer.js');
+ fs.writeFileSync(script,
+ `const fs = require('fs');\n` +
+ `console.log(fs.readFileSync(${JSON.stringify(mountPoint)} + '/data.txt', 'utf8'));\n`);
+ return script;
+}
+
+function run(args) {
+ return spawnSync(process.execPath, ['--experimental-vfs', ...args], { encoding: 'utf8' });
+}
+
+(async () => {
+ // 1. A custom file-format provider backs a --vfs-mount of its own format.
+ {
+ const target = fixture('app.customfmt');
+ fs.writeFileSync(target, Buffer.concat([
+ Buffer.from('CUSTOMFMT'), Buffer.from('hello from custom provider'),
+ ]));
+ const res = run(['-r', customProvider, `--vfs-mount=${target}`, consumerFor(target)]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /hello from custom provider/);
+ }
+
+ // 2. The built-in ZIP provider recognizes a ZIP by its bytes (named .bundle).
+ {
+ const target = fixture('app.bundle');
+ await zipFromTree({ 'data.txt': 'hello from bundled zip' }, target);
+ const res = run([`--vfs-mount=${target}`, consumerFor(target)]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /hello from bundled zip/);
+ }
+
+ // 3. A custom provider wins over the built-in ZIP provider for a real ZIP.
+ {
+ const target = fixture('real.zip');
+ await zipFromTree({ 'data.txt': 'built-in zip content' }, target);
+ const res = run(['-r', greedyProvider, `--vfs-mount=${target}`, consumerFor(target)]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /greedy provider won/);
+ assert.doesNotMatch(res.stdout, /built-in zip content/);
+ }
+
+ // 4. A custom provider can claim a directory, overriding RealFSProvider.
+ {
+ const dir = fixture('a-real-dir');
+ fs.mkdirSync(dir);
+ fs.writeFileSync(path.join(dir, 'data.txt'), 'real dir content');
+ const res = run(['-r', dirProvider, `--vfs-mount=${dir}`, consumerFor(dir)]);
+ assert.strictEqual(res.status, 0, res.stderr);
+ assert.match(res.stdout, /custom dir provider won/);
+ assert.doesNotMatch(res.stdout, /real dir content/);
+ }
+})().then(common.mustCall());
diff --git a/test/parallel/test-vfs-zip-provider-dispose.js b/test/parallel/test-vfs-zip-provider-dispose.js
new file mode 100644
index 000000000000..b93ed914a701
--- /dev/null
+++ b/test/parallel/test-vfs-zip-provider-dispose.js
@@ -0,0 +1,93 @@
+// Flags: --experimental-vfs
+'use strict';
+
+// Exercises ZipProvider's Symbol.dispose / Symbol.asyncDispose aliases:
+// disposing a provider closes its backing archive (a ZipFile releases its
+// file descriptor; a ZipBuffer has nothing to close, so disposal is a no-op),
+// and the aliases work with the `using` / `await using` declarations.
+
+const common = require('../common');
+const tmpdir = require('../common/tmpdir');
+const assert = require('assert');
+const path = require('path');
+const fsPromises = require('fs/promises');
+const zlib = require('zlib');
+const vfs = require('node:vfs');
+
+tmpdir.refresh();
+
+async function buildArchive(entries) {
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+async function writeArchive(name) {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const filePath = path.join(tmpdir.path, name);
+ await fsPromises.writeFile(filePath, archive);
+ return filePath;
+}
+
+(async () => {
+ // Symbol.asyncDispose closes a ZipFile-backed provider's archive.
+ {
+ const zip = await zlib.ZipFile.open(await writeArchive('dispose-async.zip'));
+ let closed = 0;
+ const original = zip.close.bind(zip);
+ zip.close = async (...args) => { closed++; return original(...args); };
+
+ const provider = new vfs.ZipProvider(zip);
+ await provider[Symbol.asyncDispose]();
+ assert.strictEqual(closed, 1);
+ }
+
+ // Symbol.dispose closes a ZipFile-backed provider's archive synchronously.
+ {
+ const zip = zlib.ZipFile.openSync(await writeArchive('dispose-sync.zip'));
+ let closed = 0;
+ const original = zip.closeSync.bind(zip);
+ zip.closeSync = (...args) => { closed++; return original(...args); };
+
+ const provider = new vfs.ZipProvider(zip);
+ provider[Symbol.dispose]();
+ assert.strictEqual(closed, 1);
+ }
+
+ // `await using` disposes the provider (and closes the archive) at block exit.
+ {
+ const zip = await zlib.ZipFile.open(await writeArchive('dispose-await-using.zip'));
+ let closed = 0;
+ const original = zip.close.bind(zip);
+ zip.close = async (...args) => { closed++; return original(...args); };
+
+ {
+ await using provider = new vfs.ZipProvider(zip);
+ assert.strictEqual(provider.readonly, true);
+ assert.strictEqual(closed, 0); // Not yet disposed.
+ }
+ assert.strictEqual(closed, 1); // Disposed at block exit.
+ }
+
+ // `using` disposes the provider synchronously at block exit.
+ {
+ const zip = zlib.ZipFile.openSync(await writeArchive('dispose-using.zip'));
+ let closed = 0;
+ const original = zip.closeSync.bind(zip);
+ zip.closeSync = (...args) => { closed++; return original(...args); };
+
+ {
+ using provider = new vfs.ZipProvider(zip);
+ assert.strictEqual(provider.readonly, true);
+ }
+ assert.strictEqual(closed, 1);
+ }
+
+ // A ZipBuffer has nothing to close: both aliases are safe no-ops.
+ {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const provider = new vfs.ZipProvider(new zlib.ZipBuffer(archive));
+ provider[Symbol.dispose](); // Does not throw.
+ await provider[Symbol.asyncDispose](); // Does not throw.
+ }
+})().then(common.mustCall());
diff --git a/test/parallel/test-vfs-zip-provider-handle.js b/test/parallel/test-vfs-zip-provider-handle.js
new file mode 100644
index 000000000000..3c88eacbb8c9
--- /dev/null
+++ b/test/parallel/test-vfs-zip-provider-handle.js
@@ -0,0 +1,514 @@
+// Flags: --experimental-vfs
+'use strict';
+
+// Additional ZipProvider coverage beyond test-vfs-zip-provider.js:
+// direct ZipFileHandle read/write/stat/truncate (both explicit and
+// current position, append-mode positioning, buffer growth), readFile/
+// writeFile encoding and data-type variants, compression-method-preserving
+// rename() (methodOption()), ZipFile-backed synchronous delete, an explicit
+// empty-directory open() EISDIR, the readdir child-directory dedup path, and
+// a few error branches (EROFS on open(), ENOTDIR on rmdir(), ENOENT on
+// rename()) not exercised by the base test.
+
+const common = require('../common');
+const tmpdir = require('../common/tmpdir');
+const assert = require('assert');
+const path = require('path');
+const fs = require('fs');
+const fsPromises = require('fs/promises');
+const zlib = require('zlib');
+const vfs = require('node:vfs');
+
+tmpdir.refresh();
+
+async function buildArchive(entries, comment) {
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+function buildArchiveSync(entries, comment) {
+ return Buffer.concat([...zlib.createZipArchiveSync(entries, comment)]);
+}
+
+// --- direct ZipFileHandle read/write/stat/truncate (async) -------------
+(async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('hello world')),
+ ]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ const handle = await provider.open('/a.txt', 'r+');
+
+ // Explicit position leaves the handle's own position untouched.
+ const buf = Buffer.alloc(5);
+ const { bytesRead } = await handle.read(buf, 0, 5, 0);
+ assert.strictEqual(bytesRead, 5);
+ assert.strictEqual(buf.toString(), 'hello');
+ assert.strictEqual(handle.position, 0);
+
+ // Current position (undefined/null/-1) advances the handle's position.
+ const buf2 = Buffer.alloc(5);
+ await handle.read(buf2, 0, 5, null);
+ assert.strictEqual(buf2.toString(), 'hello');
+ assert.strictEqual(handle.position, 5);
+ const buf3 = Buffer.alloc(1);
+ await handle.read(buf3, 0, 1, -1);
+ assert.strictEqual(buf3.toString(), ' ');
+ assert.strictEqual(handle.position, 6);
+
+ // Reading past EOF yields 0 bytes without advancing further than available.
+ const tail = Buffer.alloc(100);
+ const { bytesRead: tailRead } = await handle.read(tail, 0, 100, 6);
+ assert.strictEqual(tailRead, 5); // 'world'.length
+
+ // write() at an explicit position (overwrite), then at the current
+ // position (append past the buffer's current size, forcing growth).
+ await handle.write(Buffer.from('HELLO'), 0, 5, 0);
+ const stat1 = await handle.stat();
+ assert.strictEqual(stat1.size, 11);
+
+ handle.position = 11;
+ await handle.write(Buffer.from('!!!'), 0, 3, null);
+ const stat2 = await handle.stat();
+ assert.strictEqual(stat2.size, 14);
+
+ await handle.truncate(5);
+ const stat3 = await handle.stat();
+ assert.strictEqual(stat3.size, 5);
+
+ await handle.truncate(8);
+ const stat4 = await handle.stat();
+ assert.strictEqual(stat4.size, 8);
+
+ await handle.close();
+
+ const archiveVfs = vfs.create(provider);
+ const final = await archiveVfs.promises.readFile('/a.txt');
+ assert.strictEqual(final.length, 8);
+ assert.strictEqual(final.subarray(0, 5).toString(), 'HELLO');
+})().then(common.mustCall());
+
+// --- direct ZipFileHandle read/write/stat/truncate (sync) --------------
+(() => {
+ const archive = buildArchiveSync([zlib.ZipEntry.createSync('b.txt', Buffer.from('0123456789'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+ const handle = provider.openSync('/b.txt', 'r+');
+
+ const buf = Buffer.alloc(4);
+ const { bytesRead } = handle.readSync(buf, 0, 4, 2);
+ assert.strictEqual(bytesRead, 4);
+ assert.strictEqual(buf.toString(), '2345');
+
+ handle.writeSync(Buffer.from('AB'), 0, 2, 0);
+ assert.strictEqual(handle.statSync().size, 10);
+
+ // A write growing well beyond current capacity exercises #ensureCapacity's
+ // doubling path.
+ handle.writeSync(Buffer.alloc(1000, 0x58 /* 'X' */), 0, 1000, 10);
+ assert.strictEqual(handle.statSync().size, 1010);
+
+ handle.truncateSync(3);
+ assert.strictEqual(handle.statSync().size, 3);
+ assert.strictEqual(handle.readFileSync('utf8'), 'AB2');
+
+ handle.closeSync();
+})();
+
+// --- append-mode positioning: writes always land at the current end -------
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('c.txt', Buffer.from('xy'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ const handle = await provider.open('/c.txt', 'a');
+ assert.strictEqual(handle.position, 2); // Positioned at EOF on open
+
+ // Even with an explicit (wrong) position, append mode writes at the end.
+ await handle.write(Buffer.from('z'), 0, 1, 0);
+ assert.strictEqual((await handle.stat()).size, 3);
+ await handle.close();
+
+ const archiveVfs = vfs.create(provider);
+ assert.strictEqual(await archiveVfs.promises.readFile('/c.txt', 'utf8'), 'xyz');
+})().then(common.mustCall());
+
+// --- readFile/readFileSync encoding variants + writeFile data types --------
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('d.txt', Buffer.from('café'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ const handle = await provider.open('/d.txt', 'r');
+ const asBuffer = await handle.readFile();
+ assert.ok(Buffer.isBuffer(asBuffer));
+ const asStringShorthand = await handle.readFile('utf8');
+ assert.strictEqual(asStringShorthand, 'café');
+ const asStringOption = await handle.readFile({ encoding: 'utf8' });
+ assert.strictEqual(asStringOption, 'café');
+ const asExplicitBuffer = await handle.readFile({ encoding: 'buffer' });
+ assert.ok(Buffer.isBuffer(asExplicitBuffer));
+ await handle.close();
+
+ const writeHandle = await provider.open('/e.txt', 'w');
+ await writeHandle.writeFile(Buffer.from('buffer-data'));
+ await writeHandle.close();
+ assert.strictEqual(await (await provider.open('/e.txt', 'r')).readFile('utf8'), 'buffer-data');
+
+ const writeHandle2 = await provider.open('/f.txt', 'w');
+ await writeHandle2.writeFile('string-data', { encoding: 'utf8' });
+ await writeHandle2.close();
+ assert.strictEqual(await (await provider.open('/f.txt', 'r')).readFile('utf8'), 'string-data');
+})().then(common.mustCall());
+
+(() => {
+ const archive = buildArchiveSync([zlib.ZipEntry.createSync('g.txt', Buffer.from('sync-café'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ const handle = provider.openSync('/g.txt', 'r');
+ assert.ok(Buffer.isBuffer(handle.readFileSync()));
+ assert.strictEqual(handle.readFileSync('utf8'), 'sync-café');
+ assert.strictEqual(handle.readFileSync({ encoding: 'utf8' }), 'sync-café');
+ assert.ok(Buffer.isBuffer(handle.readFileSync({ encoding: 'buffer' })));
+ handle.closeSync();
+
+ const writeHandle = provider.openSync('/h.txt', 'w');
+ writeHandle.writeFileSync(Buffer.from('sync-buffer-data'));
+ writeHandle.closeSync();
+ assert.strictEqual(provider.openSync('/h.txt', 'r').readFileSync('utf8'), 'sync-buffer-data');
+})();
+
+// --- rename() preserves the original compression method (methodOption) ----
+(async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('store.bin', Buffer.from('a'.repeat(100)), { method: 'store' }),
+ await zlib.ZipEntry.create('deflate.bin', Buffer.from('b'.repeat(100)), { method: 'deflate' }),
+ await zlib.ZipEntry.create('zstd.bin', Buffer.from('c'.repeat(100)), { method: 'zstd' }),
+ ]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+ const archiveVfs = vfs.create(provider);
+
+ const methodsBefore = new Map([
+ ['store.bin', zip.get('store.bin').method],
+ ['deflate.bin', zip.get('deflate.bin').method],
+ ['zstd.bin', zip.get('zstd.bin').method],
+ ]);
+
+ await archiveVfs.promises.rename('/store.bin', '/store-renamed.bin');
+ await archiveVfs.promises.rename('/deflate.bin', '/deflate-renamed.bin');
+ await archiveVfs.promises.rename('/zstd.bin', '/zstd-renamed.bin');
+
+ assert.strictEqual(zip.get('store-renamed.bin').method, methodsBefore.get('store.bin'));
+ assert.strictEqual(zip.get('deflate-renamed.bin').method, methodsBefore.get('deflate.bin'));
+ assert.strictEqual(zip.get('zstd-renamed.bin').method, methodsBefore.get('zstd.bin'));
+
+ // Content must still round-trip correctly under the reproduced method.
+ assert.strictEqual((await zip.get('store-renamed.bin').content()).toString(), 'a'.repeat(100));
+ assert.strictEqual((await zip.get('deflate-renamed.bin').content()).toString(), 'b'.repeat(100));
+ assert.strictEqual((await zip.get('zstd-renamed.bin').content()).toString(), 'c'.repeat(100));
+})().then(common.mustCall());
+
+(() => {
+ const archive = buildArchiveSync([
+ zlib.ZipEntry.createSync('store.bin', Buffer.from('a'.repeat(100)), { method: 'store' }),
+ zlib.ZipEntry.createSync('zstd.bin', Buffer.from('c'.repeat(100)), { method: 'zstd' }),
+ ]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+ const archiveVfs = vfs.create(provider);
+
+ const storeMethod = zip.get('store.bin').method;
+ const zstdMethod = zip.get('zstd.bin').method;
+ archiveVfs.renameSync('/store.bin', '/store-renamed.bin');
+ archiveVfs.renameSync('/zstd.bin', '/zstd-renamed.bin');
+ assert.strictEqual(zip.get('store-renamed.bin').method, storeMethod);
+ assert.strictEqual(zip.get('zstd-renamed.bin').method, zstdMethod);
+})();
+
+// --- rename() with a missing source is rejected with ENOENT ---------------
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('only.txt', Buffer.from('x'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+ const archiveVfs = vfs.create(provider);
+
+ await assert.rejects(
+ archiveVfs.promises.rename('/missing.txt', '/renamed.txt'),
+ { code: 'ENOENT' },
+ );
+ assert.throws(
+ () => archiveVfs.renameSync('/missing.txt', '/renamed.txt'),
+ { code: 'ENOENT' },
+ );
+})().then(common.mustCall());
+
+// --- rmdir() on a plain file is rejected with ENOTDIR ----------------------
+// (called on the provider directly: the vfs router validates directory-ness
+// itself before delegating for some operations, which would otherwise never
+// exercise ZipProvider's own check).
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('file.txt', Buffer.from('x'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ await assert.rejects(provider.rmdir('/file.txt'), { code: 'ENOTDIR' });
+ assert.throws(() => provider.rmdirSync('/file.txt'), { code: 'ENOTDIR' });
+})().then(common.mustCall());
+
+// --- open(): EEXIST/ENOENT/EISDIR-on-wrong-direction, called directly on
+// the provider so the router can't short-circuit before delegating ---------
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ await assert.rejects(provider.open('/a.txt', 'wx'), { code: 'EEXIST' });
+ assert.throws(() => provider.openSync('/a.txt', 'wx'), { code: 'EEXIST' });
+ await assert.rejects(provider.open('/missing.txt', 'r'), { code: 'ENOENT' });
+ assert.throws(() => provider.openSync('/missing.txt', 'r'), { code: 'ENOENT' });
+
+ // A handle opened write-only can't be read from, and vice versa.
+ const writeOnly = await provider.open('/w.txt', 'w');
+ await assert.rejects(writeOnly.read(Buffer.alloc(1), 0, 1, 0), { code: 'EISDIR' });
+ await writeOnly.close();
+ const readOnly = await provider.open('/a.txt', 'r');
+ await assert.rejects(readOnly.write(Buffer.alloc(1), 0, 1, 0), { code: 'EISDIR' });
+ await readOnly.close();
+})().then(common.mustCall());
+
+// --- normalize(): a path without a leading slash is used as-is ------------
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ const stats = await provider.stat('a.txt');
+ assert.strictEqual(stats.isFile(), true);
+})().then(common.mustCall());
+
+// --- mkdir(): both the with-options and no-options shapes, called
+// directly on the provider ---------------------------------------------
+(async () => {
+ const archive = await buildArchive([]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ await provider.mkdir('/no-opts');
+ assert.strictEqual((await provider.stat('/no-opts')).isDirectory(), true);
+ await provider.mkdir('/with-opts', { mode: 0o700 });
+ assert.strictEqual((await provider.stat('/with-opts')).isDirectory(), true);
+
+ provider.mkdirSync('/no-opts-sync');
+ assert.strictEqual(provider.statSync('/no-opts-sync').isDirectory(), true);
+ provider.mkdirSync('/with-opts-sync', { mode: 0o700 });
+ assert.strictEqual(provider.statSync('/with-opts-sync').isDirectory(), true);
+})().then(common.mustCall());
+
+// --- readdir() skips a directory's own explicit entry when listing it -----
+(async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('dir/', Buffer.alloc(0)),
+ await zlib.ZipEntry.create('dir/child.txt', Buffer.from('x')),
+ ]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+ const archiveVfs = vfs.create(provider);
+
+ const entries = await archiveVfs.promises.readdir('/dir');
+ assert.deepStrictEqual(entries, ['child.txt']);
+})().then(common.mustCall());
+
+// --- an entry not made by a Unix zip tool reports mode 0, so stat() and
+// rename() fall back to their documented defaults --------------------------
+(async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('foreign.txt', Buffer.from('x')),
+ await zlib.ZipEntry.create('foreign-dir/', Buffer.alloc(0)),
+ ]);
+ const tampered = Buffer.from(archive);
+ // The central header's "version made by" high byte selects the platform;
+ // anything other than 3 (Unix) makes `mode` report 0 (see zip.js's
+ // `CentralFileHeader.prototype.mode`).
+ // The central directory follows *all* entries' local sections, and each
+ // entry's central header follows the previous entries' central headers.
+ const localSectionsLength = (30 + 'foreign.txt'.length + 'x'.length) +
+ (30 + 'foreign-dir/'.length + 0);
+ const fileHeaderStart = localSectionsLength;
+ const dirHeaderStart = fileHeaderStart + (46 + 'foreign.txt'.length);
+ const fileMadeByOffset = fileHeaderStart + 5;
+ const dirMadeByOffset = dirHeaderStart + 5;
+ assert.strictEqual(tampered[fileMadeByOffset], 3); // sanity: was Unix-made
+ assert.strictEqual(tampered[dirMadeByOffset], 3);
+ tampered[fileMadeByOffset] = 0; // MS-DOS/FAT
+ tampered[dirMadeByOffset] = 0;
+
+ const zip = new zlib.ZipBuffer(tampered);
+ assert.strictEqual(zip.get('foreign.txt').mode, 0);
+ assert.strictEqual(zip.get('foreign-dir/').mode, 0);
+ const provider = new vfs.ZipProvider(zip);
+ const archiveVfs = vfs.create(provider);
+
+ // stat()'s `entry.mode || ` fallback, for both a file and an
+ // (explicit) directory entry.
+ const fileStats = await archiveVfs.promises.stat('/foreign.txt');
+ assert.strictEqual(fileStats.mode & 0o777, 0o644);
+ const dirStats = await archiveVfs.promises.stat('/foreign-dir');
+ assert.strictEqual(dirStats.mode & 0o777, 0o755);
+ assert.strictEqual(archiveVfs.statSync('/foreign.txt').mode & 0o777, 0o644);
+ assert.strictEqual(archiveVfs.statSync('/foreign-dir').mode & 0o777, 0o755);
+
+ // rename()'s `mode: entry.mode || undefined` fallback (undefined lets
+ // ZipEntry.create() pick its own default mode instead of reproducing 0).
+ await archiveVfs.promises.rename('/foreign.txt', '/renamed.txt');
+ assert.strictEqual((await archiveVfs.promises.stat('/renamed.txt')).mode & 0o777, 0o644);
+})().then(common.mustCall());
+
+(() => {
+ const archive = buildArchiveSync([zlib.ZipEntry.createSync('foreign.txt', Buffer.from('x'))]);
+ const tampered = Buffer.from(archive);
+ const centralHeaderStart = 30 + 'foreign.txt'.length + 'x'.length;
+ const madeByOffset = centralHeaderStart + 5;
+ tampered[madeByOffset] = 0;
+
+ const zip = new zlib.ZipBuffer(tampered);
+ const provider = new vfs.ZipProvider(zip);
+ const archiveVfs = vfs.create(provider);
+
+ archiveVfs.renameSync('/foreign.txt', '/renamed.txt');
+ assert.strictEqual(archiveVfs.statSync('/renamed.txt').mode & 0o777, 0o644);
+})();
+
+// --- opening an explicit (empty) directory entry is rejected with EISDIR --
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('empty-dir/', Buffer.alloc(0))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ await assert.rejects(provider.open('/empty-dir', 'r'), { code: 'EISDIR' });
+ assert.throws(() => provider.openSync('/empty-dir', 'r'), { code: 'EISDIR' });
+})().then(common.mustCall());
+
+// --- readdir dedups a child directory reached through multiple entries ----
+(async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('dir/one.txt', Buffer.from('1')),
+ await zlib.ZipEntry.create('dir/two.txt', Buffer.from('2')),
+ ]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+ const archiveVfs = vfs.create(provider);
+
+ // Both entries imply the same 'dir' child at the root; it must appear once.
+ const rootEntries = await archiveVfs.promises.readdir('/');
+ assert.deepStrictEqual(rootEntries, ['dir']);
+ const dirEntries = await archiveVfs.promises.readdir('/dir');
+ assert.deepStrictEqual(dirEntries.sort(), ['one.txt', 'two.txt']);
+})().then(common.mustCall());
+
+// --- open() with a write flag against a readonly (ZipFile) archive: EROFS --
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const filePath = path.join(tmpdir.path, 'vfs-archive-handle-readonly.zip');
+ await fsPromises.writeFile(filePath, archive);
+ const zip = await zlib.ZipFile.open(filePath);
+ const provider = new vfs.ZipProvider(zip);
+
+ await assert.rejects(provider.open('/new.txt', 'w'), { code: 'EROFS' });
+ assert.throws(() => provider.openSync('/new.txt', 'w'), { code: 'EROFS' });
+
+ await zip.close();
+})().then(common.mustCall());
+
+// --- ZipFile-backed writable archive: sync unlink/rmdir (deleteSync) ------
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const filePath = path.join(tmpdir.path, 'vfs-archive-handle-writable-sync.zip');
+ await fsPromises.writeFile(filePath, archive);
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ const provider = new vfs.ZipProvider(zip);
+ const archiveVfs = vfs.create(provider);
+
+ archiveVfs.mkdirSync('/somedir');
+ assert.strictEqual(archiveVfs.statSync('/somedir').isDirectory(), true);
+ archiveVfs.rmdirSync('/somedir');
+ assert.throws(() => archiveVfs.statSync('/somedir'), { code: 'ENOENT' });
+
+ archiveVfs.unlinkSync('/a.txt');
+ assert.throws(() => archiveVfs.statSync('/a.txt'), { code: 'ENOENT' });
+
+ await zip.close();
+})().then(common.mustCall());
+
+// --- numeric open flags are normalized like node:fs (O_* constants) --------
+(async () => {
+ const { O_RDONLY, O_WRONLY, O_CREAT, O_TRUNC } = fs.constants;
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('hello'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ // A numeric read-only flag reads the existing entry.
+ const rh = await provider.open('/a.txt', O_RDONLY);
+ assert.strictEqual(await rh.readFile('utf8'), 'hello');
+ await rh.close();
+
+ // A numeric write+create+truncate flag creates and commits a new entry.
+ const wh = await provider.open('/num.txt', O_WRONLY | O_CREAT | O_TRUNC);
+ await wh.writeFile('written via numeric flags');
+ await wh.close();
+ assert.strictEqual(await provider.readFile('/num.txt', 'utf8'), 'written via numeric flags');
+
+ // The synchronous surface normalizes numeric flags identically.
+ const sh = provider.openSync('/a.txt', O_RDONLY);
+ assert.strictEqual(sh.readFileSync('utf8'), 'hello');
+ sh.closeSync();
+ const swh = provider.openSync('/num-sync.txt', O_WRONLY | O_CREAT | O_TRUNC);
+ swh.writeFileSync('sync numeric');
+ swh.closeSync();
+ assert.strictEqual(provider.readFileSync('/num-sync.txt', 'utf8'), 'sync numeric');
+})().then(common.mustCall());
+
+// --- readonly archive: any writable open flag (including 'r+' and numeric
+// O_RDWR) is rejected with EROFS, matching MemoryProvider/real fs -----------
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const filePath = path.join(tmpdir.path, 'vfs-archive-handle-rplus-readonly.zip');
+ await fsPromises.writeFile(filePath, archive);
+ const zip = await zlib.ZipFile.open(filePath); // read-only
+ const provider = new vfs.ZipProvider(zip);
+
+ // 'r+' opens for writing, so it is refused even though it also reads.
+ await assert.rejects(provider.open('/a.txt', 'r+'), { code: 'EROFS' });
+ assert.throws(() => provider.openSync('/a.txt', 'r+'), { code: 'EROFS' });
+
+ // Numeric O_RDWR is likewise a writable flag.
+ await assert.rejects(provider.open('/a.txt', fs.constants.O_RDWR), { code: 'EROFS' });
+ assert.throws(() => provider.openSync('/a.txt', fs.constants.O_RDWR), { code: 'EROFS' });
+
+ // Plain read access is still allowed.
+ const h = await provider.open('/a.txt', 'r');
+ assert.strictEqual(await h.readFile('utf8'), 'x');
+ await h.close();
+
+ await zip.close();
+})().then(common.mustCall());
+
+// --- mkdir({ recursive: true }) over an existing FILE still throws EEXIST;
+// over an existing DIRECTORY it stays a no-op -------------------------------
+(async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+
+ await assert.rejects(provider.mkdir('/a.txt', { recursive: true }), { code: 'EEXIST' });
+ assert.throws(() => provider.mkdirSync('/a.txt', { recursive: true }), { code: 'EEXIST' });
+
+ // Recursive mkdir over an existing directory remains a no-op (no throw).
+ await provider.mkdir('/d');
+ await provider.mkdir('/d', { recursive: true });
+ provider.mkdirSync('/d', { recursive: true });
+ assert.strictEqual((await provider.stat('/d')).isDirectory(), true);
+})().then(common.mustCall());
diff --git a/test/parallel/test-vfs-zip-provider.js b/test/parallel/test-vfs-zip-provider.js
new file mode 100644
index 000000000000..ad137457f7a3
--- /dev/null
+++ b/test/parallel/test-vfs-zip-provider.js
@@ -0,0 +1,243 @@
+// Flags: --experimental-vfs
+'use strict';
+
+// Exercises ZipProvider (node:vfs backed by node:zlib's ZipBuffer/
+// ZipFile): construction validation, readonly reflecting the archive's own
+// writability, stat/readdir over explicit and implicit directories, and the
+// full async and synchronous CRUD surface, against both a ZipBuffer and a
+// ZipFile (opened both via open() and openSync()) source.
+
+const common = require('../common');
+const tmpdir = require('../common/tmpdir');
+const assert = require('assert');
+const path = require('path');
+const fsPromises = require('fs/promises');
+const zlib = require('zlib');
+const vfs = require('node:vfs');
+
+tmpdir.refresh();
+
+async function buildArchive(entries, comment) {
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+(async () => {
+ // Construction validation.
+ assert.throws(() => new vfs.ZipProvider({}), { code: 'ERR_INVALID_ARG_TYPE' });
+ assert.throws(() => new vfs.ZipProvider(null), { code: 'ERR_INVALID_ARG_TYPE' });
+
+ // --- ZipBuffer-backed: always writable ------------------------------------
+ {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('hello')),
+ await zlib.ZipEntry.create('dir/b.txt', Buffer.from('nested')),
+ await zlib.ZipEntry.create('empty-dir/', Buffer.alloc(0)),
+ ]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+ assert.strictEqual(provider.readonly, false);
+ assert.strictEqual(provider.supportsSymlinks, false);
+ assert.strictEqual(provider.supportsWatch, false);
+
+ const archiveVfs = vfs.create(provider);
+
+ // stat: file, implicit directory, explicit directory, root.
+ const fileStat = await archiveVfs.promises.stat('/a.txt');
+ assert.strictEqual(fileStat.isFile(), true);
+ assert.strictEqual(fileStat.size, 5);
+
+ const implicitDirStat = await archiveVfs.promises.stat('/dir');
+ assert.strictEqual(implicitDirStat.isDirectory(), true);
+
+ const explicitDirStat = await archiveVfs.promises.stat('/empty-dir');
+ assert.strictEqual(explicitDirStat.isDirectory(), true);
+
+ const rootStat = await archiveVfs.promises.stat('/');
+ assert.strictEqual(rootStat.isDirectory(), true);
+
+ await assert.rejects(archiveVfs.promises.stat('/missing.txt'), { code: 'ENOENT' });
+
+ // readdir: root lists both files and directories, deduped.
+ const rootEntries = await archiveVfs.promises.readdir('/');
+ assert.deepStrictEqual(rootEntries.sort(), ['a.txt', 'dir', 'empty-dir']);
+
+ const dirEntries = await archiveVfs.promises.readdir('/dir');
+ assert.deepStrictEqual(dirEntries, ['b.txt']);
+
+ const withTypes = await archiveVfs.promises.readdir('/', { withFileTypes: true });
+ const byName = new Map(withTypes.map((d) => [d.name, d]));
+ assert.strictEqual(byName.get('a.txt').isFile(), true);
+ assert.strictEqual(byName.get('dir').isDirectory(), true);
+
+ await assert.rejects(archiveVfs.promises.readdir('/a.txt'), { code: 'ENOTDIR' });
+ await assert.rejects(
+ archiveVfs.promises.readdir('/', { recursive: true }),
+ { code: 'ERR_METHOD_NOT_IMPLEMENTED' },
+ );
+
+ // readFile / writeFile round trip (new file).
+ assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'hello');
+ await archiveVfs.promises.writeFile('/new.txt', 'brand new');
+ assert.strictEqual(await archiveVfs.promises.readFile('/new.txt', 'utf8'), 'brand new');
+ assert.strictEqual(zip.has('new.txt'), true);
+
+ // Overwriting an existing file.
+ await archiveVfs.promises.writeFile('/a.txt', 'overwritten');
+ assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'overwritten');
+
+ // appendFile.
+ await archiveVfs.promises.writeFile('/append.txt', 'ab');
+ await archiveVfs.promises.appendFile('/append.txt', 'cd');
+ assert.strictEqual(await archiveVfs.promises.readFile('/append.txt', 'utf8'), 'abcd');
+
+ // mkdir + rmdir.
+ await archiveVfs.promises.mkdir('/newdir');
+ assert.strictEqual((await archiveVfs.promises.stat('/newdir')).isDirectory(), true);
+ await assert.rejects(archiveVfs.promises.mkdir('/newdir'), { code: 'EEXIST' });
+ await archiveVfs.promises.mkdir('/newdir', { recursive: true }); // No throw, already exists
+ await archiveVfs.promises.rmdir('/newdir');
+ await assert.rejects(archiveVfs.promises.stat('/newdir'), { code: 'ENOENT' });
+
+ // Rmdir refuses a non-empty directory.
+ await assert.rejects(archiveVfs.promises.rmdir('/dir'), { code: 'ENOTEMPTY' });
+
+ // unlink.
+ await archiveVfs.promises.unlink('/append.txt');
+ await assert.rejects(archiveVfs.promises.stat('/append.txt'), { code: 'ENOENT' });
+ await assert.rejects(archiveVfs.promises.unlink('/missing.txt'), { code: 'ENOENT' });
+ await assert.rejects(archiveVfs.promises.unlink('/dir'), { code: 'EISDIR' });
+
+ // rename.
+ await archiveVfs.promises.writeFile('/rename-me.txt', 'content');
+ await archiveVfs.promises.rename('/rename-me.txt', '/renamed.txt');
+ assert.strictEqual(zip.has('rename-me.txt'), false);
+ assert.strictEqual(await archiveVfs.promises.readFile('/renamed.txt', 'utf8'), 'content');
+
+ // open() flag semantics.
+ await assert.rejects(archiveVfs.promises.open('/does-not-exist.txt', 'r'), { code: 'ENOENT' });
+ await assert.rejects(archiveVfs.promises.open('/a.txt', 'wx'), { code: 'EEXIST' });
+ await assert.rejects(archiveVfs.promises.open('/dir', 'r'), { code: 'EISDIR' });
+ }
+
+ // --- ZipFile-backed, read-only: writes rejected with EROFS ----------------
+ {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const filePath = path.join(tmpdir.path, 'vfs-archive-readonly.zip');
+ await fsPromises.writeFile(filePath, archive);
+ const zip = await zlib.ZipFile.open(filePath);
+ const provider = new vfs.ZipProvider(zip);
+ assert.strictEqual(provider.readonly, true);
+ const archiveVfs = vfs.create(provider);
+
+ assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'x');
+ await assert.rejects(archiveVfs.promises.writeFile('/new.txt', 'y'), { code: 'EROFS' });
+ await assert.rejects(archiveVfs.promises.unlink('/a.txt'), { code: 'EROFS' });
+ await assert.rejects(archiveVfs.promises.mkdir('/newdir'), { code: 'EROFS' });
+ await assert.rejects(archiveVfs.promises.rmdir('/newdir'), { code: 'EROFS' });
+ await assert.rejects(archiveVfs.promises.rename('/a.txt', '/b.txt'), { code: 'EROFS' });
+
+ // The synchronous surface rejects the same way.
+ assert.strictEqual(archiveVfs.readFileSync('/a.txt', 'utf8'), 'x');
+ assert.throws(() => archiveVfs.writeFileSync('/new.txt', 'y'), { code: 'EROFS' });
+ assert.throws(() => archiveVfs.unlinkSync('/a.txt'), { code: 'EROFS' });
+ assert.throws(() => archiveVfs.mkdirSync('/newdir'), { code: 'EROFS' });
+ assert.throws(() => archiveVfs.rmdirSync('/newdir'), { code: 'EROFS' });
+ assert.throws(() => archiveVfs.renameSync('/a.txt', '/b.txt'), { code: 'EROFS' });
+
+ await zip.close();
+ }
+
+ // --- ZipFile-backed, opened writable: mutations persist to disk ----------
+ {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const filePath = path.join(tmpdir.path, 'vfs-archive-writable.zip');
+ await fsPromises.writeFile(filePath, archive);
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ const provider = new vfs.ZipProvider(zip);
+ assert.strictEqual(provider.readonly, false);
+ const archiveVfs = vfs.create(provider);
+
+ await archiveVfs.promises.writeFile('/b.txt', 'new content');
+ await zip.close();
+
+ const reopened = await zlib.ZipFile.open(filePath);
+ assert.strictEqual((await (await reopened.get('b.txt')).content()).toString(), 'new content');
+ await reopened.close();
+ }
+
+ // --- ZipBuffer-backed, fully synchronous CRUD ------------------------------
+ {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('hello')),
+ await zlib.ZipEntry.create('dir/b.txt', Buffer.from('nested')),
+ ]);
+ const zip = new zlib.ZipBuffer(archive);
+ const provider = new vfs.ZipProvider(zip);
+ const archiveVfs = vfs.create(provider);
+
+ // stat/readdir.
+ assert.strictEqual(archiveVfs.statSync('/a.txt').isFile(), true);
+ assert.strictEqual(archiveVfs.statSync('/dir').isDirectory(), true);
+ assert.throws(() => archiveVfs.statSync('/missing.txt'), { code: 'ENOENT' });
+ assert.deepStrictEqual(archiveVfs.readdirSync('/').sort(), ['a.txt', 'dir']);
+ assert.throws(() => archiveVfs.readdirSync('/a.txt'), { code: 'ENOTDIR' });
+ assert.throws(
+ () => archiveVfs.readdirSync('/', { recursive: true }),
+ { code: 'ERR_METHOD_NOT_IMPLEMENTED' },
+ );
+
+ // readFile/writeFile/appendFile round trip.
+ assert.strictEqual(archiveVfs.readFileSync('/a.txt', 'utf8'), 'hello');
+ archiveVfs.writeFileSync('/new.txt', 'brand new');
+ assert.strictEqual(archiveVfs.readFileSync('/new.txt', 'utf8'), 'brand new');
+ archiveVfs.appendFileSync('/new.txt', '!');
+ assert.strictEqual(archiveVfs.readFileSync('/new.txt', 'utf8'), 'brand new!');
+
+ // mkdir/rmdir.
+ archiveVfs.mkdirSync('/newdir');
+ assert.strictEqual(archiveVfs.statSync('/newdir').isDirectory(), true);
+ assert.throws(() => archiveVfs.mkdirSync('/newdir'), { code: 'EEXIST' });
+ archiveVfs.mkdirSync('/newdir', { recursive: true }); // No throw, already exists.
+ archiveVfs.rmdirSync('/newdir');
+ assert.throws(() => archiveVfs.statSync('/newdir'), { code: 'ENOENT' });
+ assert.throws(() => archiveVfs.rmdirSync('/dir'), { code: 'ENOTEMPTY' });
+
+ // unlink.
+ archiveVfs.unlinkSync('/new.txt');
+ assert.throws(() => archiveVfs.statSync('/new.txt'), { code: 'ENOENT' });
+ assert.throws(() => archiveVfs.unlinkSync('/missing.txt'), { code: 'ENOENT' });
+ assert.throws(() => archiveVfs.unlinkSync('/dir'), { code: 'EISDIR' });
+
+ // rename.
+ archiveVfs.writeFileSync('/rename-me.txt', 'content');
+ archiveVfs.renameSync('/rename-me.txt', '/renamed.txt');
+ assert.strictEqual(zip.has('rename-me.txt'), false);
+ assert.strictEqual(archiveVfs.readFileSync('/renamed.txt', 'utf8'), 'content');
+
+ // open() flag semantics.
+ assert.throws(() => archiveVfs.openSync('/does-not-exist.txt', 'r'), { code: 'ENOENT' });
+ assert.throws(() => archiveVfs.openSync('/a.txt', 'wx'), { code: 'EEXIST' });
+ assert.throws(() => archiveVfs.openSync('/dir', 'r'), { code: 'EISDIR' });
+ }
+
+ // --- ZipFile-backed via openSync: sync-only round trip on disk -----------
+ {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const filePath = path.join(tmpdir.path, 'vfs-archive-opensync.zip');
+ await fsPromises.writeFile(filePath, archive);
+ const zip = zlib.ZipFile.openSync(filePath, { writable: true });
+ const provider = new vfs.ZipProvider(zip);
+ assert.strictEqual(provider.readonly, false);
+ const archiveVfs = vfs.create(provider);
+
+ assert.strictEqual(archiveVfs.readFileSync('/a.txt', 'utf8'), 'x');
+ archiveVfs.writeFileSync('/b.txt', 'new content');
+ zip.closeSync();
+
+ const reopened = zlib.ZipFile.openSync(filePath);
+ assert.strictEqual(reopened.getSync('b.txt').contentSync().toString(), 'new content');
+ reopened.closeSync();
+ }
+})().then(common.mustCall());
diff --git a/test/parallel/test-zlib-zip-vfs.js b/test/parallel/test-zlib-zip-vfs.js
new file mode 100644
index 000000000000..cfb0f10b65c5
--- /dev/null
+++ b/test/parallel/test-zlib-zip-vfs.js
@@ -0,0 +1,183 @@
+// Flags: --experimental-vfs
+'use strict';
+
+// Exercises the node:zlib ZipFile read/write surface over a node:vfs virtual
+// file descriptor. An in-memory archive is placed in a mounted MemoryProvider
+// VFS and opened with ZipFile through its mounted path, so every fd operation
+// ZipFile performs - positional read/write, fstat, ftruncate, open/close, in
+// both the asynchronous and the synchronous flavour - is served by the VFS
+// rather than a real OS fd. This validates that ZipFile's fd abstraction is
+// complete enough to run unchanged on a purely virtual descriptor.
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const fs = require('node:fs');
+const path = require('node:path');
+const { test } = require('node:test');
+const vfs = require('node:vfs');
+
+let mountCounter = 0;
+
+async function buildArchive(entries, comment) {
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+// Places `archive` in a freshly mounted in-memory VFS and returns the mounted
+// path of the archive file plus a cleanup that unmounts the VFS.
+function mountArchive(archive) {
+ const mountPoint = path.resolve(`/vfs-zip-${process.pid}-${mountCounter++}`);
+ const memfs = vfs.create();
+ memfs.writeFileSync('/archive.zip', archive); // provider-relative, before mount
+ memfs.mount(mountPoint);
+ return { vpath: path.join(mountPoint, 'archive.zip'), cleanup: () => memfs.unmount() };
+}
+
+test('ZipFile.open() reads a VFS-backed archive through an async virtual fd', async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('hello vfs')),
+ await zlib.ZipEntry.create('dir/b.bin', Buffer.from([1, 2, 3, 4]), { method: 'store' }),
+ await zlib.ZipEntry.create('z.txt', Buffer.from('Z'.repeat(4096)), { method: 'zstd' }),
+ ]);
+ const { vpath, cleanup } = mountArchive(archive);
+ try {
+ const zf = await zlib.ZipFile.open(vpath);
+ try {
+ assert.strictEqual(zf.size, 3);
+ assert.deepStrictEqual([...zf.keys()].sort(), ['a.txt', 'dir/b.bin', 'z.txt']);
+
+ // A lazy, file-backed entry: it retains no content and reads straight
+ // from the virtual fd on demand.
+ const a = await zf.get('a.txt');
+ assert.strictEqual(a.rawContent, null);
+ assert.strictEqual((await a.content()).toString(), 'hello vfs');
+ assert.strictEqual(await zf.get('a.txt'), a); // Cached handle identity
+
+ // The contentIterator() streams (decompressing on the way) from the fd.
+ const chunks = [];
+ for await (const c of (await zf.get('z.txt')).contentIterator()) chunks.push(c);
+ assert.strictEqual(Buffer.concat(chunks).toString(), 'Z'.repeat(4096));
+
+ // stream() resolves to a Readable over the virtual fd.
+ const rs = await zf.stream('dir/b.bin');
+ const out = [];
+ for await (const c of rs) out.push(c);
+ assert.deepStrictEqual(Buffer.concat(out), Buffer.from([1, 2, 3, 4]));
+ } finally {
+ await zf.close();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test('ZipFile.openSync() reads a VFS-backed archive through a synchronous virtual fd', async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('sync hello')),
+ await zlib.ZipEntry.create('b.bin', Buffer.from([9, 8, 7]), { method: 'store' }),
+ ]);
+ const { vpath, cleanup } = mountArchive(archive);
+ try {
+ const zf = zlib.ZipFile.openSync(vpath);
+ try {
+ assert.strictEqual(zf.getSync('a.txt').contentSync().toString(), 'sync hello');
+ assert.deepStrictEqual([...zf.getSync('b.bin').contentSync()], [9, 8, 7]);
+ assert.deepStrictEqual([...zf.valuesSync()].map((e) => e.name).sort(), ['a.txt', 'b.bin']);
+ } finally {
+ zf.closeSync();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test('ZipFile writable mutations run against an async virtual fd', async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('keep.txt', Buffer.from('keep')),
+ await zlib.ZipEntry.create('drop.txt', Buffer.from('drop')),
+ ]);
+ const { vpath, cleanup } = mountArchive(archive);
+ try {
+ const sizeBefore = fs.statSync(vpath).size;
+ const zw = await zlib.ZipFile.open(vpath, { writable: true });
+ try {
+ await zw.add('added.txt', Buffer.from('a new member over vfs'));
+ assert.strictEqual(await zw.delete('drop.txt'), true);
+ } finally {
+ await zw.close();
+ }
+ // Positional writes + ftruncate actually altered the virtual file.
+ assert.notStrictEqual(fs.statSync(vpath).size, sizeBefore);
+
+ const zr = await zlib.ZipFile.open(vpath);
+ try {
+ assert.deepStrictEqual([...zr.keys()].sort(), ['added.txt', 'keep.txt']);
+ assert.strictEqual((await (await zr.get('added.txt')).content()).toString(), 'a new member over vfs');
+ assert.strictEqual(zr.has('drop.txt'), false);
+ } finally {
+ await zr.close();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test('ZipFile sync mutations (addEntrySync) run against a synchronous virtual fd', async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('a'))]);
+ const { vpath, cleanup } = mountArchive(archive);
+ try {
+ const zw = zlib.ZipFile.openSync(vpath, { writable: true });
+ try {
+ zw.addSync('b.txt', Buffer.from('b sync'));
+ assert.deepStrictEqual([...zw.keys()].sort(), ['a.txt', 'b.txt']);
+ } finally {
+ zw.closeSync();
+ }
+ const zr = zlib.ZipFile.openSync(vpath);
+ try {
+ assert.strictEqual(zr.getSync('b.txt').contentSync().toString(), 'b sync');
+ } finally {
+ zr.closeSync();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test('addEntry() promotes a streaming entry to file-backed on a virtual fd', async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('seed.txt', Buffer.from('seed'))]);
+ const { vpath, cleanup } = mountArchive(archive);
+ try {
+ const payload = 'streamed over vfs'.repeat(32);
+ const zw = await zlib.ZipFile.open(vpath, { writable: true });
+ try {
+ async function* source() {
+ yield Buffer.from(payload.slice(0, 5));
+ yield Buffer.from(payload.slice(5));
+ }
+ const streamEntry = zlib.ZipEntry.createStream('s.txt', source());
+ const returned = await zw.addEntry(streamEntry);
+ assert.strictEqual(returned, streamEntry);
+
+ // Promoted in place against the virtual fd: now readable and re-streamable.
+ assert.strictEqual((await streamEntry.content()).toString(), payload);
+ const it = [];
+ for await (const c of streamEntry.contentIterator()) it.push(c);
+ assert.strictEqual(Buffer.concat(it).toString(), payload);
+ } finally {
+ await zw.close();
+ }
+
+ const zr = await zlib.ZipFile.open(vpath);
+ try {
+ assert.strictEqual((await (await zr.get('s.txt')).content()).toString(), payload);
+ } finally {
+ await zr.close();
+ }
+ } finally {
+ cleanup();
+ }
+});