nodejs/node · #66235

vfs: add ComposableProvider for layered mounts

mcollina · merged Oct 5, 20264 files · 586 + / 0 −
doc/api/vfs.md51 + / 0 −
@@ -594,6 +594,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@@ -703,6 +753,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
lib/internal/vfs/providers/composable.jsadded416 + / 0 −
@@ -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 };
lib/vfs.js2 + / 0 −
@@ -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');@@ -36,6 +37,7 @@ module.exports = {   VirtualFileSystem,   VirtualProvider,   MemoryProvider,+  ComposableProvider,   RealFSProvider,   ZipProvider, };
test/parallel/test-vfs-composable-provider.jsadded117 + / 0 −
@@ -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' });