Commit ad55417d8a4 for nodejs
commit ad55417d8a4374e90a4154f80070f77c3b247585
Author: Matteo Collina <hello@matteocollina.com>
Date: Mon Oct 5 07:17:09 2026 +0200
vfs: add ComposableProvider for layered mounts
Allow providers to be layered at one mount point. Reads search layers
in priority order, while writes copy lower-layer files to the first
provider and deletions hide lower copies without changing them.
Assisted-by: pi
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/66235
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
diff --git a/doc/api/vfs.md b/doc/api/vfs.md
index 9742f64114c..e40cd1241f8 100644
--- a/doc/api/vfs.md
+++ b/doc/api/vfs.md
@@ -650,6 +650,56 @@ provider.setReadOnly();
myVfs.writeFileSync('/x.txt', 'fail'); // throws EROFS
```
+## Class: `ComposableProvider`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+[`ComposableProvider`][] combines one or more providers in priority order. The first
+provider is the writable layer; reads search from first to last. Directories
+are merged, with entries in higher-priority layers shadowing entries with the
+same name in lower layers. Writes to a lower file copy it to the first provider
+before changing it. Removing a file hides lower copies without deleting them.
+The first provider must be writable to change the composed file system.
+
+### `new ComposableProvider(providers)`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `providers` {VirtualProvider\[]} Non-empty array of providers, ordered from
+ highest to lowest priority.
+
+```cjs
+const vfs = require('node:vfs');
+
+const memory = new vfs.MemoryProvider();
+const disk = new vfs.RealFSProvider('/tmp/vfs-root');
+const combined = vfs.create(new vfs.ComposableProvider([memory, disk]));
+combined.writeFileSync('/config.json', '{"debug":true}');
+// The file in memory shadows /tmp/vfs-root/config.json.
+```
+
+### `composableProvider.providers`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* {VirtualProvider\[]}
+
+A copy of the ordered provider list. Changes to this array do not affect the
+composition. File handles opened before a write continue to refer to the layer
+on which they were opened. Watching a path watches only its currently selected
+provider, not changes across the entire composition. Symbolic links are
+resolved by the provider containing them, not across providers. Traversal
+through a symbolic-link directory is not supported by the composition. Renaming
+a directory over a directory that exists only in a lower layer is not supported.
+Layer selection and copy-up use synchronous provider operations, including
+when invoked through the asynchronous VFS API.
+
## Class: `RealFSProvider`
<!-- YAML
@@ -759,6 +809,7 @@ fields use synthetic but stable values:
[`--import`]: cli.md#--importmodule
[`--require`]: cli.md#-r---require-module
[`--vfs-load`]: cli.md#--vfs-loadsource
+[`ComposableProvider`]: #class-composableprovider
[`MemoryProvider`]: #class-memoryprovider
[`RealFSProvider`]: #class-realfsprovider
[`VirtualFileSystem`]: #class-virtualfilesystem
diff --git a/lib/internal/vfs/providers/composable.js b/lib/internal/vfs/providers/composable.js
new file mode 100644
index 00000000000..ea35332d18b
--- /dev/null
+++ b/lib/internal/vfs/providers/composable.js
@@ -0,0 +1,416 @@
+'use strict';
+
+const {
+ ArrayIsArray,
+ ArrayPrototypePush,
+ MapPrototypeHas,
+ MapPrototypeSet,
+ SafeMap,
+ SafeSet,
+ SetPrototypeAdd,
+ SetPrototypeHas,
+} = primordials;
+
+const {
+ codes: {
+ ERR_INVALID_ARG_TYPE,
+ ERR_OUT_OF_RANGE,
+ },
+} = require('internal/errors');
+const { VirtualProvider } = require('internal/vfs/provider');
+const { decodeOpenFlags } = require('internal/vfs/file_handle');
+const {
+ createENOENT,
+ createENOTDIR,
+ createENOTEMPTY,
+ createEISDIR,
+ createEROFS,
+ createEEXIST,
+ createEXDEV,
+} = require('internal/vfs/errors');
+const { posix: pathPosix } = require('path');
+const { Dirent } = require('internal/fs/utils');
+const { fs: { COPYFILE_EXCL } } = internalBinding('constants');
+const {
+ UV_DIRENT_DIR,
+ UV_DIRENT_FILE,
+ UV_DIRENT_LINK,
+ UV_DIRENT_BLOCK,
+ UV_DIRENT_CHAR,
+ UV_DIRENT_FIFO,
+ UV_DIRENT_SOCKET,
+ UV_DIRENT_UNKNOWN,
+} = internalBinding('uv');
+
+function direntType(entry) {
+ if (entry.isDirectory()) return UV_DIRENT_DIR;
+ if (entry.isFile()) return UV_DIRENT_FILE;
+ if (entry.isSymbolicLink()) return UV_DIRENT_LINK;
+ if (entry.isBlockDevice()) return UV_DIRENT_BLOCK;
+ if (entry.isCharacterDevice()) return UV_DIRENT_CHAR;
+ if (entry.isFIFO()) return UV_DIRENT_FIFO;
+ if (entry.isSocket()) return UV_DIRENT_SOCKET;
+ return UV_DIRENT_UNKNOWN;
+}
+
+// Providers are ordered from highest priority to lowest. Reads select the
+// first visible entry; directories merge children across layers, but an upper
+// file hides the lower entry and its descendants. All writes target the first
+// provider. Mutating a lower entry copies it there first, without changing
+// the lower provider. Deletions record whiteouts that hide lower entries (and
+// their descendants) without removing them; a new entry in the first provider
+// remains visible at a whiteouted path. Open handles remain bound to the
+// provider that supplied them.
+class ComposableProvider extends VirtualProvider {
+ #providers;
+ #whiteouts = new SafeSet();
+
+ constructor(providers) {
+ super();
+ if (!ArrayIsArray(providers)) {
+ throw new ERR_INVALID_ARG_TYPE('providers', 'Array', providers);
+ }
+ if (providers.length === 0) {
+ throw new ERR_OUT_OF_RANGE('providers.length', '>= 1', 0);
+ }
+ for (let i = 0; i < providers.length; i++) {
+ if (!(providers[i] instanceof VirtualProvider)) {
+ throw new ERR_INVALID_ARG_TYPE(`providers[${i}]`, 'VirtualProvider', providers[i]);
+ }
+ }
+ this.#providers = providers.slice();
+ }
+
+ get providers() { return this.#providers.slice(); }
+ get readonly() { return this.#providers[0].readonly; }
+ get supportsSymlinks() { return this.#providers[0].supportsSymlinks; }
+ get supportsWatch() { return this.#providers[0].supportsWatch; }
+
+ #normalize(path) { return pathPosix.resolve('/', path); }
+
+ #isWhiteout(path) {
+ while (path !== '/') {
+ if (SetPrototypeHas(this.#whiteouts, path)) return true;
+ path = pathPosix.dirname(path);
+ }
+ return false;
+ }
+
+ #statAt(provider, path) {
+ try {
+ return provider.lstatSync(path);
+ } catch (err) {
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return undefined;
+ throw err;
+ }
+ }
+
+ // Resolve every ancestor through the union, so a file in an upper layer
+ // prevents falling through to a lower directory's children.
+ #find(path, syscall = 'stat') {
+ if (path !== '/') {
+ const parent = this.#find(pathPosix.dirname(path), syscall);
+ if (!parent.stats.isDirectory()) throw createENOTDIR(syscall, path);
+ }
+ for (let i = 0; i < this.#providers.length; i++) {
+ if (i > 0 && this.#isWhiteout(path)) break;
+ const stats = this.#statAt(this.#providers[i], path);
+ if (stats) return { index: i, stats };
+ }
+ throw createENOENT(syscall, path);
+ }
+
+ #existsEntry(path) {
+ try { return this.#find(path); } catch (err) {
+ if (err?.code === 'ENOENT') return undefined;
+ throw err;
+ }
+ }
+
+ #top() { return this.#providers[0]; }
+ #writable(syscall, path) {
+ if (this.readonly) throw createEROFS(syscall, path);
+ return this.#top();
+ }
+
+ #ensureParent(path) {
+ const parent = pathPosix.dirname(path);
+ if (parent === '/') return;
+ const { stats } = this.#find(parent, 'open');
+ if (!stats.isDirectory()) throw createENOTDIR('open', path);
+ if (!this.#statAt(this.#top(), parent)) {
+ this.#ensureParent(parent);
+ this.#top().mkdirSync(parent);
+ }
+ }
+
+ #copyUp(path) {
+ const { index, stats } = this.#find(path, 'open');
+ if (index === 0) return;
+ this.#ensureParent(path);
+ const source = this.#providers[index];
+ if (stats.isDirectory()) {
+ this.#top().mkdirSync(path);
+ } else if (stats.isSymbolicLink()) {
+ this.#top().symlinkSync(source.readlinkSync(path), path);
+ } else {
+ this.#top().writeFileSync(path, source.readFileSync(path), { mode: stats.mode });
+ }
+ }
+
+ #copyTree(path) {
+ const { stats } = this.#find(path);
+ this.#copyUp(path);
+ if (stats.isDirectory()) {
+ const names = this.readdirSync(path);
+ for (let i = 0; i < names.length; i++) {
+ this.#copyTree(pathPosix.join(path, names[i]));
+ }
+ }
+ }
+
+ #prepareOpen(path, flags) {
+ const access = decodeOpenFlags(flags);
+ const found = this.#existsEntry(path);
+ if (access.create && access.exclusive && found) throw createEEXIST('open', path);
+ if (!access.writable && found) return this.#providers[found.index];
+ if (!access.writable && !access.create) throw createENOENT('open', path);
+ const top = this.#writable('open', path);
+ if (found) {
+ if (found.index !== 0) {
+ if (access.writable && (!access.truncate || !access.create)) this.#copyUp(path);
+ else this.#ensureParent(path);
+ }
+ } else if (access.create) {
+ this.#ensureParent(path);
+ } else {
+ throw createENOENT('open', path);
+ }
+ return top;
+ }
+
+ openSync(path, flags = 'r', mode) {
+ path = this.#normalize(path);
+ return this.#prepareOpen(path, flags).openSync(path, flags, mode);
+ }
+ async open(path, flags = 'r', mode) {
+ path = this.#normalize(path);
+ return this.#prepareOpen(path, flags).open(path, flags, mode);
+ }
+
+ statSync(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path).index].statSync(path, options);
+ }
+ async stat(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path).index].stat(path, options);
+ }
+ lstatSync(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path).index].lstatSync(path, options);
+ }
+ async lstat(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path).index].lstat(path, options);
+ }
+
+ readdirSync(path, options) {
+ path = this.#normalize(path);
+ if (!this.#find(path, 'scandir').stats.isDirectory()) throw createENOTDIR('scandir', path);
+ if (options?.recursive) {
+ const result = [];
+ const visit = (dir, prefix) => {
+ const entries = this.readdirSync(dir, { withFileTypes: true });
+ for (let i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+ const name = prefix + entry.name;
+ ArrayPrototypePush(result, options.withFileTypes ?
+ new Dirent(name, direntType(entry), path) : name);
+ if (entry.isDirectory()) visit(pathPosix.join(dir, entry.name), name + '/');
+ }
+ };
+ visit(path, '');
+ return result;
+ }
+ const seen = new SafeMap();
+ for (let i = 0; i < this.#providers.length; i++) {
+ if (i > 0 && this.#isWhiteout(path)) break;
+ const stats = this.#statAt(this.#providers[i], path);
+ if (!stats || !stats.isDirectory()) continue;
+ const entries = this.#providers[i].readdirSync(path, { withFileTypes: true });
+ for (let j = 0; j < entries.length; j++) {
+ const entry = entries[j];
+ if (!MapPrototypeHas(seen, entry.name) &&
+ (i === 0 || !this.#isWhiteout(pathPosix.join(path, entry.name)))) {
+ MapPrototypeSet(seen, entry.name, new Dirent(entry.name, direntType(entry), path));
+ }
+ }
+ }
+ return options?.withFileTypes ? [...seen.values()] : [...seen.keys()];
+ }
+ async readdir(path, options) { return this.readdirSync(path, options); }
+
+ mkdirSync(path, options) {
+ path = this.#normalize(path);
+ this.#writable('mkdir', path);
+ const found = this.#existsEntry(path);
+ if (found) {
+ if (options?.recursive && found.stats.isDirectory()) return undefined;
+ throw createEEXIST('mkdir', path);
+ }
+ if (options?.recursive && pathPosix.dirname(path) !== '/') {
+ this.mkdirSync(pathPosix.dirname(path), options);
+ }
+ this.#ensureParent(path);
+ return this.#top().mkdirSync(path, options);
+ }
+ async mkdir(path, options) { return this.mkdirSync(path, options); }
+
+ unlinkSync(path) {
+ path = this.#normalize(path);
+ this.#writable('unlink', path);
+ if (this.#find(path, 'unlink').stats.isDirectory()) {
+ // Let the upper provider produce its native error when possible.
+ if (this.#statAt(this.#top(), path)) this.#top().unlinkSync(path);
+ throw createEISDIR('unlink', path);
+ }
+ if (this.#statAt(this.#top(), path)) this.#top().unlinkSync(path);
+ SetPrototypeAdd(this.#whiteouts, path);
+ }
+ async unlink(path) { this.unlinkSync(path); }
+
+ rmdirSync(path) {
+ path = this.#normalize(path);
+ this.#writable('rmdir', path);
+ this.#find(path, 'rmdir');
+ if (this.readdirSync(path).length !== 0) throw createENOTEMPTY('rmdir', path);
+ if (this.#statAt(this.#top(), path)) this.#top().rmdirSync(path);
+ SetPrototypeAdd(this.#whiteouts, path);
+ }
+ async rmdir(path) { this.rmdirSync(path); }
+
+ renameSync(oldPath, newPath) {
+ oldPath = this.#normalize(oldPath);
+ newPath = this.#normalize(newPath);
+ this.#writable('rename', oldPath);
+ if (oldPath === newPath) { this.#find(oldPath, 'rename'); return; }
+ // Renaming a directory onto a lower-only directory cannot be done
+ // atomically without replacing its entire subtree.
+ const source = this.#find(oldPath, 'rename');
+ const destination = this.#existsEntry(newPath);
+ if (destination) {
+ if (source.stats.isDirectory() && !destination.stats.isDirectory()) {
+ throw createENOTDIR('rename', newPath);
+ }
+ if (!source.stats.isDirectory() && destination.stats.isDirectory()) {
+ throw createEISDIR('rename', newPath);
+ }
+ if (destination.stats.isDirectory()) {
+ if (this.readdirSync(newPath).length !== 0) {
+ throw createENOTEMPTY('rename', newPath);
+ }
+ if (!this.#statAt(this.#top(), newPath)) {
+ throw createEXDEV('rename', newPath);
+ }
+ }
+ }
+ if (source.stats.isDirectory()) this.#copyTree(oldPath);
+ else this.#copyUp(oldPath);
+ this.#ensureParent(newPath);
+ this.#top().renameSync(oldPath, newPath);
+ SetPrototypeAdd(this.#whiteouts, oldPath);
+ SetPrototypeAdd(this.#whiteouts, newPath);
+ }
+ async rename(oldPath, newPath) { this.renameSync(oldPath, newPath); }
+
+ copyFileSync(src, dest, mode) {
+ src = this.#normalize(src);
+ dest = this.#normalize(dest);
+ this.#writable('copyfile', dest);
+ const destination = this.#existsEntry(dest);
+ if (mode & COPYFILE_EXCL && destination) throw createEEXIST('copyfile', dest);
+ if (destination?.stats.isDirectory()) throw createEISDIR('copyfile', dest);
+ const data = this.readFileSync(src);
+ this.#ensureParent(dest);
+ this.#top().writeFileSync(dest, data);
+ }
+ async copyFile(src, dest, mode) { this.copyFileSync(src, dest, mode); }
+
+ linkSync(existingPath, newPath) {
+ existingPath = this.#normalize(existingPath);
+ newPath = this.#normalize(newPath);
+ this.#writable('link', newPath);
+ if (this.#existsEntry(newPath)) throw createEEXIST('link', newPath);
+ this.#copyUp(existingPath);
+ this.#ensureParent(newPath);
+ this.#top().linkSync(existingPath, newPath);
+ }
+ async link(existingPath, newPath) { this.linkSync(existingPath, newPath); }
+
+ symlinkSync(target, path, type) {
+ path = this.#normalize(path);
+ this.#writable('symlink', path);
+ if (this.#existsEntry(path)) throw createEEXIST('symlink', path);
+ this.#ensureParent(path);
+ this.#top().symlinkSync(target, path, type);
+ }
+ async symlink(target, path, type) { this.symlinkSync(target, path, type); }
+
+ readlinkSync(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'readlink').index].readlinkSync(path, options);
+ }
+ async readlink(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'readlink').index].readlink(path, options);
+ }
+ realpathSync(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'realpath').index].realpathSync(path, options);
+ }
+ async realpath(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'realpath').index].realpath(path, options);
+ }
+ accessSync(path, mode) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'access').index].accessSync(path, mode);
+ }
+ async access(path, mode) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'access').index].access(path, mode);
+ }
+
+ #metadata(path, method, args) {
+ path = this.#normalize(path);
+ this.#writable(method, path);
+ this.#copyUp(path);
+ return this.#top()[method](path, ...args);
+ }
+ chmodSync(path, mode) { return this.#metadata(path, 'chmodSync', [mode]); }
+ lchmodSync(path, mode) { return this.#metadata(path, 'lchmodSync', [mode]); }
+ chownSync(path, uid, gid) { return this.#metadata(path, 'chownSync', [uid, gid]); }
+ lchownSync(path, uid, gid) { return this.#metadata(path, 'lchownSync', [uid, gid]); }
+ utimesSync(path, atime, mtime) { return this.#metadata(path, 'utimesSync', [atime, mtime]); }
+ lutimesSync(path, atime, mtime) { return this.#metadata(path, 'lutimesSync', [atime, mtime]); }
+
+ watch(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'watch').index].watch(path, options);
+ }
+ watchAsync(path, options) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'watch').index].watchAsync(path, options);
+ }
+ watchFile(path, options, listener) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'watch').index].watchFile(path, options, listener);
+ }
+ unwatchFile(path, listener) {
+ path = this.#normalize(path);
+ return this.#providers[this.#find(path, 'watch').index].unwatchFile(path, listener);
+ }
+}
+
+module.exports = { ComposableProvider };
diff --git a/lib/vfs.js b/lib/vfs.js
index be47f408adf..bcce0f6ffbc 100644
--- a/lib/vfs.js
+++ b/lib/vfs.js
@@ -7,6 +7,7 @@ const {
const { VirtualFileSystem } = require('internal/vfs/file_system');
const { VirtualProvider } = require('internal/vfs/provider');
const { MemoryProvider } = require('internal/vfs/providers/memory');
+const { ComposableProvider } = require('internal/vfs/providers/composable');
const { RealFSProvider } = require('internal/vfs/providers/real');
const { ZipProvider } = require('internal/vfs/providers/ziparchive');
const { registerProvider } = require('internal/vfs/provider_registry');
@@ -47,6 +48,7 @@ module.exports = {
VirtualFileSystem,
VirtualProvider,
MemoryProvider,
+ ComposableProvider,
RealFSProvider,
ZipProvider,
};
diff --git a/test/parallel/test-vfs-composable-provider.js b/test/parallel/test-vfs-composable-provider.js
new file mode 100644
index 00000000000..c02e8e50773
--- /dev/null
+++ b/test/parallel/test-vfs-composable-provider.js
@@ -0,0 +1,117 @@
+// Flags: --experimental-vfs
+'use strict';
+
+const common = require('../common');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const zlib = require('node:zlib');
+const vfs = require('node:vfs');
+const tmpdir = require('../common/tmpdir');
+
+tmpdir.refresh();
+const root = tmpdir.resolve('lower');
+fs.mkdirSync(path.join(root, 'dir'), { recursive: true });
+fs.writeFileSync(path.join(root, 'shared.txt'), 'lower');
+fs.writeFileSync(path.join(root, 'dir', 'lower.txt'), 'from disk');
+fs.writeFileSync(path.join(root, 'deleted.txt'), 'still on disk');
+fs.mkdirSync(path.join(root, 'masked'));
+fs.writeFileSync(path.join(root, 'masked', 'lower.txt'), 'lower');
+
+const upper = new vfs.MemoryProvider();
+const lower = new vfs.RealFSProvider(root);
+const provider = new vfs.ComposableProvider([upper, lower]);
+const view = vfs.create(provider, { emitExperimentalWarning: false });
+
+assert.ok(provider instanceof vfs.VirtualProvider);
+assert.deepStrictEqual(provider.providers, [upper, lower]);
+assert.notStrictEqual(provider.providers, provider.providers);
+assert.strictEqual(view.readFileSync('/shared.txt', 'utf8'), 'lower');
+assert.strictEqual(view.readFileSync('/dir/lower.txt', 'utf8'), 'from disk');
+assert.deepStrictEqual(view.readdirSync('/dir'), ['lower.txt']);
+
+// Writes copy up; neither the file nor its parent on the lower layer changes.
+view.appendFileSync('/shared.txt', '+upper');
+assert.strictEqual(view.readFileSync('/shared.txt', 'utf8'), 'lower+upper');
+assert.strictEqual(fs.readFileSync(path.join(root, 'shared.txt'), 'utf8'), 'lower');
+view.writeFileSync('/dir/upper.txt', 'only in memory');
+assert.deepStrictEqual(view.readdirSync('/dir').sort(), ['lower.txt', 'upper.txt']);
+assert.strictEqual(provider.readdirSync('/dir', { withFileTypes: true })[0].parentPath,
+ '/dir');
+assert.strictEqual(fs.existsSync(path.join(root, 'dir', 'upper.txt')), false);
+assert.strictEqual(view.statSync('/dir/lower.txt').isFile(), true);
+
+// Whiteouts hide lower files even after removing an upper shadow.
+view.unlinkSync('/deleted.txt');
+assert.strictEqual(view.existsSync('/deleted.txt'), false);
+view.writeFileSync('/deleted.txt', 'recreated');
+assert.strictEqual(view.readFileSync('/deleted.txt', 'utf8'), 'recreated');
+assert.ok(view.readdirSync('/').includes('deleted.txt'));
+view.unlinkSync('/deleted.txt');
+assert.strictEqual(view.existsSync('/deleted.txt'), false);
+assert.strictEqual(fs.readFileSync(path.join(root, 'deleted.txt'), 'utf8'), 'still on disk');
+
+// A file in the upper layer must mask a directory in a lower layer.
+upper.writeFileSync('/masked', 'shadow');
+assert.strictEqual(view.readFileSync('/masked', 'utf8'), 'shadow');
+assert.throws(() => view.readFileSync('/masked/lower.txt'), { code: 'ENOTDIR' });
+upper.unlinkSync('/masked');
+
+assert.throws(() => view.openSync('/shared.txt', 'wx'), { code: 'EEXIST' });
+assert.throws(() => view.copyFileSync('/shared.txt', '/dir/lower.txt', fs.constants.COPYFILE_EXCL),
+ { code: 'EEXIST' });
+
+(async () => {
+ assert.strictEqual(await view.promises.readFile('/dir/lower.txt', 'utf8'), 'from disk');
+ await view.promises.writeFile('/dir/lower.txt', 'async shadow');
+ assert.strictEqual(await view.promises.readFile('/dir/lower.txt', 'utf8'), 'async shadow');
+ assert.strictEqual(fs.readFileSync(path.join(root, 'dir', 'lower.txt'), 'utf8'), 'from disk');
+ assert.deepStrictEqual((await view.promises.readdir('/dir')).sort(), ['lower.txt', 'upper.txt']);
+ view.renameSync('/dir/lower.txt', '/dir/renamed.txt');
+ assert.strictEqual(view.existsSync('/dir/lower.txt'), false);
+ assert.strictEqual(view.readFileSync('/dir/renamed.txt', 'utf8'), 'async shadow');
+ assert.strictEqual(fs.existsSync(path.join(root, 'dir', 'lower.txt')), true);
+
+ const chunks = [];
+ const entries = [
+ await zlib.ZipEntry.create('zip.txt', Buffer.from('from zip')),
+ await zlib.ZipEntry.create('dir/archive.txt', Buffer.from('archived')),
+ ];
+ for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk);
+ const archive = new vfs.ZipProvider(new zlib.ZipBuffer(Buffer.concat(chunks)));
+ const overlay = new vfs.MemoryProvider();
+ const layered = vfs.create(new vfs.ComposableProvider([overlay, archive, lower]),
+ { emitExperimentalWarning: false });
+ assert.strictEqual(layered.readFileSync('/zip.txt', 'utf8'), 'from zip');
+ assert.deepStrictEqual(layered.readdirSync('/dir').sort(),
+ ['archive.txt', 'lower.txt']);
+ layered.writeFileSync('/dir/archive.txt', 'in memory');
+ assert.strictEqual(layered.readFileSync('/dir/archive.txt', 'utf8'), 'in memory');
+ assert.strictEqual(archive.readFileSync('/dir/archive.txt', 'utf8'), 'archived');
+ const fd = layered.openSync('/dir/lower.txt', fs.constants.O_WRONLY);
+ layered.closeSync(fd);
+ assert.strictEqual(layered.readFileSync('/dir/lower.txt', 'utf8'), 'from disk');
+ layered.unlinkSync('/zip.txt');
+ assert.strictEqual(layered.existsSync('/zip.txt'), false);
+ assert.strictEqual(archive.readFileSync('/zip.txt', 'utf8'), 'from zip');
+ assert.throws(() => layered.unlinkSync('/dir'), { code: 'EISDIR' });
+ assert.deepStrictEqual(layered.readdirSync('/dir', { recursive: true }).sort(),
+ ['archive.txt', 'lower.txt']);
+ const mount = layered.mount();
+ try {
+ assert.strictEqual(fs.readFileSync(path.join(mount, 'dir', 'archive.txt'), 'utf8'),
+ 'in memory');
+ } finally {
+ layered.unmount();
+ }
+ const readonly = new vfs.MemoryProvider();
+ readonly.setReadOnly();
+ const readView = vfs.create(new vfs.ComposableProvider([readonly, lower]),
+ { emitExperimentalWarning: false });
+ assert.strictEqual(readView.readFileSync('/shared.txt', 'utf8'), 'lower');
+ assert.strictEqual(readView.readonly, true);
+ assert.throws(() => readView.writeFileSync('/shared.txt', 'fail'), { code: 'EROFS' });
+})().then(common.mustCall());
+
+assert.throws(() => new vfs.ComposableProvider([]), { code: 'ERR_OUT_OF_RANGE' });
+assert.throws(() => new vfs.ComposableProvider([{}]), { code: 'ERR_INVALID_ARG_TYPE' });