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' });