nodejs/node · #66235
vfs: add ComposableProvider for layered mounts
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-virtualfilesystemlib/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' });