Commit a18fd89ac5b for nodejs
commit a18fd89ac5b444391758514f344db3b377bc7a12
Author: Philipp Dunkel <pipobscure@users.noreply.github.com>
Date: Fri Sep 25 11:37:00 2026 +0200
vfs: make the reserved root readable through fs
The reserved root `${os.devNull}/vfs`, which holds the mount points of
all virtual file systems, could not be read: fs calls on it fell through
to the real file system, so nothing could list what was mounted.
While any file system is mounted, serve the root as a read-only
directory. It lists every mount point by the last segment of its path, a
recursive listing descends into each mounted file system, and paths
under it that no mount serves report ENOENT. Creating, removing or
changing entries in it fails with EROFS. When nothing is mounted it does
not exist, as before.
Add vfs.vfsBase(), which returns the path of that directory, so that a
program can read it without spelling the path out.
A mount point cannot be removed or renamed, nor replaced by a rename:
rmdir() and rename() fail with EBUSY, and a recursive rm() empties the
file system and then fails the same way. Before, rmdir() of an empty
mount point reported success without doing anything.
The callback and promise forms of readdir() with `withFileTypes` now
report each Dirent's parentPath as a host path, as readdirSync() did,
instead of the provider-relative one, and split recursive names such as
`dir/file.txt` into their directory and base name. A recursive listing
joins subdirectories with the host separator rather than `/`, which
mixed separators on Windows. realpath() of a mount point no longer
returns it with a trailing separator.
Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
PR-URL: https://github.com/nodejs/node/pull/66140
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
diff --git a/doc/api/vfs.md b/doc/api/vfs.md
index edbb1c306df..9742f64114c 100644
--- a/doc/api/vfs.md
+++ b/doc/api/vfs.md
@@ -152,6 +152,29 @@ $ node --experimental-vfs --require ./provider.js \
--vfs-load archive.customfmt
```
+## `vfs.vfsBase()`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Returns: {string} The absolute path of the [reserved root directory][].
+
+Returns the directory that holds the mount points of every mounted virtual file
+system, which is `path.join(os.devNull, 'vfs')`. Reading it lists what is
+mounted; see [The reserved root directory][reserved root directory].
+
+```cjs
+const vfs = require('node:vfs');
+const fs = require('node:fs');
+
+const myVfs = vfs.create();
+const mountPoint = myVfs.mount();
+
+fs.readdirSync(vfs.vfsBase()); // The name of every mount point in it
+mountPoint.startsWith(vfs.vfsBase()); // true
+```
+
## Class: `VirtualFileSystem`
<!-- YAML
@@ -187,9 +210,11 @@ After mounting, files in the VFS can be accessed through the
using paths under the returned mount point.
Mount points always live inside a reserved namespace that cannot have child file system entries,
-so virtual paths never conflate with (or shadow) real paths. The virtual path scheme is subject to
-change and users should not manually construct them based on assumptions. Instead, obtain
-them from what `vfs.mount()` returns or `vfs.mountPoint`.
+so virtual paths never conflate with (or shadow) real paths. A mount point is obtained from what
+`vfs.mount()` returns or from [`vfs.mountPoint`][], and the mount points of all mounted file
+systems can be listed by reading the [reserved root directory][], whose path [`vfs.vfsBase()`][]
+returns. The name of a mount point within that directory is assigned at runtime, so it is not
+something to construct or hard-code.
```cjs
const vfs = require('node:vfs');
@@ -203,6 +228,11 @@ const mountPoint = myVfs.mount();
fs.readFileSync(`${mountPoint}/data.txt`, 'utf8'); // 'Hello'
```
+Like any mount point, the mount point cannot be removed or renamed, nor
+replaced by renaming something else onto it: [`fs.rmdir()`][] and
+[`fs.rename()`][] fail with `EBUSY`. A recursive [`fs.rm()`][] of the mount
+point empties the file system before failing the same way.
+
Each `VirtualFileSystem` instance may be mounted at most once at a
time. Attempting to mount an already-mounted instance throws
`ERR_INVALID_STATE`. Because each instance mounts inside its own
@@ -380,6 +410,32 @@ The promise namespace mirrors `fs.promises` and includes `readFile`,
`access`, `rm`, `truncate`, `link`, `mkdtemp`, `chmod`, `chown`, `lchown`,
`utimes`, `lutimes`, `open`, `lchmod`, and `watch`.
+## The reserved root directory
+
+While any virtual file system is mounted, the directory that holds the mount
+points can be read through [`node:fs`][]. [`vfs.vfsBase()`][] returns its path,
+`path.join(os.devNull, 'vfs')`. It contains a directory for every mounted file
+system, named like the last segment of its [`vfs.mountPoint`][].
+
+```cjs
+const vfs = require('node:vfs');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const root = vfs.vfsBase();
+const assets = vfs.create();
+assets.writeFileSync('/logo.svg', '<svg/>');
+const mountPoint = assets.mount();
+
+const name = path.basename(mountPoint);
+fs.readdirSync(root); // [ name ]
+fs.readdirSync(root, { recursive: true }); // [ name, `${name}/logo.svg` ]
+```
+
+The root directory itself is read-only. Creating, removing, or changing its
+entries fails with `EROFS`, while the file systems its entries lead to can be
+written to as usual. When nothing is mounted, the root directory does not exist.
+
## Module loader integration
Once a `VirtualFileSystem` is mounted, paths under the mount point
@@ -711,6 +767,9 @@ fields use synthetic but stable values:
[`ffi.dlopen()`]: ffi.md#ffidlopenpath-definitions
[`fs.BigIntStats`]: fs.md#class-fsstats
[`fs.Stats`]: fs.md#class-fsstats
+[`fs.rename()`]: fs.md#fsrenameoldpath-newpath-callback
+[`fs.rm()`]: fs.md#fsrmpath-options-callback
+[`fs.rmdir()`]: fs.md#fsrmdirpath-options-callback
[`import.meta.resolve()`]: esm.md#importmetaresolvespecifier
[`new ffi.DynamicLibrary()`]: ffi.md#new-dynamiclibrarypath
[`node:fs`]: fs.md
@@ -721,8 +780,10 @@ fields use synthetic but stable values:
[`vfs.mountPointURL`]: #vfsmountpointurl
[`vfs.mountPoint`]: #vfsmountpoint
[`vfs.unmount()`]: #vfsunmount
+[`vfs.vfsBase()`]: #vfsvfsbase
[`zipFile.writable`]: zlib.md#zipfilewritable
[`zlib.ZipBuffer`]: zlib.md#class-zlibzipbuffer
[`zlib.ZipFile`]: zlib.md#class-zlibzipfile
[loading from `node_modules` folders]: modules.md#loading-from-node_modules-folders
+[reserved root directory]: #the-reserved-root-directory
[the global folders]: modules.md#loading-from-the-global-folders
diff --git a/lib/internal/vfs/errors.js b/lib/internal/vfs/errors.js
index 6af91c4bf7a..b30b736e83a 100644
--- a/lib/internal/vfs/errors.js
+++ b/lib/internal/vfs/errors.js
@@ -19,6 +19,7 @@ const {
UV_EINVAL,
UV_ELOOP,
UV_EACCES,
+ UV_EBUSY,
UV_EXDEV,
} = internalBinding('uv');
@@ -180,6 +181,16 @@ function createEACCES(syscall, path) {
return err;
}
+function createEBUSY(syscall, path) {
+ const err = new UVException({
+ errno: UV_EBUSY,
+ syscall,
+ path,
+ });
+ ErrorCaptureStackTrace(err, createEBUSY);
+ return err;
+}
+
function createEXDEV(syscall, path) {
const err = new UVException({
errno: UV_EXDEV,
@@ -201,5 +212,6 @@ module.exports = {
createEINVAL,
createELOOP,
createEACCES,
+ createEBUSY,
createEXDEV,
};
diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js
index a496bd32efa..271359bf633 100644
--- a/lib/internal/vfs/file_system.js
+++ b/lib/internal/vfs/file_system.js
@@ -3,6 +3,8 @@
const {
MathRandom,
ObjectFreeze,
+ StringPrototypeLastIndexOf,
+ StringPrototypeSlice,
StringPrototypeStartsWith,
Symbol,
SymbolDispose,
@@ -21,6 +23,7 @@ const { join: joinPath } = pathPosix;
const {
getLayerRoot,
getRelativePath,
+ getVfsRoot,
} = require('internal/vfs/router');
const {
openVirtualFd,
@@ -30,6 +33,7 @@ const {
const {
createENOENT,
createEBADF,
+ createEBUSY,
createEISDIR,
} = require('internal/vfs/errors');
const { VirtualReadStream, VirtualWriteStream } = require('internal/vfs/streams');
@@ -47,6 +51,7 @@ const kNormalizedMountPoint = Symbol('kNormalizedMountPoint');
const kMounted = Symbol('kMounted');
const kPromises = Symbol('kPromises');
const kLayerId = Symbol('kLayerId');
+const kReservedRoot = Symbol('kReservedRoot');
const kLoadLayer = Symbol('kLoadLayer');
// Layer 0 is reserved for the file system --vfs-load mounts, so that source is
@@ -80,6 +85,12 @@ function randomSuffix() {
return suffix;
}
+// The root of a file system is its mount point, and like any mount point it
+// cannot be removed or renamed, nor replaced by a rename.
+function checkNotRoot(providerPath, syscall, path) {
+ if (providerPath === '/') throw createEBUSY(syscall, path);
+}
+
let registerVFS;
let deregisterVFS;
@@ -122,10 +133,20 @@ class VirtualFileSystem {
}
this[kProvider] = provider ?? new MemoryProvider();
+ this[kPromises] = null;
+ if (options[kReservedRoot] === true) {
+ // Serves the reserved root directory itself. It is not a layer, so it
+ // takes no layer id and leaves the numbering of real mounts alone.
+ const root = getVfsRoot();
+ this[kMountPoint] = root;
+ this[kNormalizedMountPoint] = normalizeMountedPath(root);
+ this[kMounted] = true;
+ this[kLayerId] = -1;
+ return;
+ }
this[kMountPoint] = null;
this[kNormalizedMountPoint] = null;
this[kMounted] = false;
- this[kPromises] = null;
this[kLayerId] = options[kLoadLayer] === true ? kLoadLayerId : nextLayerId++;
}
@@ -260,6 +281,8 @@ class VirtualFileSystem {
*/
#toMountedPath(providerPath) {
if (this[kMounted] && this[kMountPoint]) {
+ // path.join() would keep the trailing separator of the provider root.
+ if (providerPath === '/') return this[kMountPoint];
return path.join(this[kMountPoint], providerPath);
}
return providerPath;
@@ -343,28 +366,36 @@ class VirtualFileSystem {
readdirSync(dirPath, options) {
const providerPath = this.#toProviderPath(dirPath);
const result = this[kProvider].readdirSync(providerPath, options);
+ return this.#toMountedDirents(dirPath, result, options);
+ }
- // Rewrite Dirent parentPath from provider-relative to VFS path.
- if (options?.withFileTypes === true) {
- const recursive = options?.recursive === true;
- for (let i = 0; i < result.length; i++) {
- const dirent = result[i];
- if (recursive) {
- // In recursive mode, name may contain slashes (e.g. 'a/b.txt').
- const slashIdx = dirent.name.lastIndexOf('/');
- if (slashIdx !== -1) {
- const subdir = dirent.name.slice(0, slashIdx);
- dirent.parentPath = joinPath(dirPath, subdir);
- dirent.name = dirent.name.slice(slashIdx + 1);
- } else {
- dirent.parentPath = dirPath;
- }
- } else {
- dirent.parentPath = dirPath;
+ /**
+ * Rewrites the Dirents of a listing of `dirPath` from provider-relative to
+ * VFS paths, so that each `parentPath` is the directory the entry is in.
+ * @param {string} dirPath The listed directory, as given by the caller
+ * @param {string[]|Dirent[]} result The provider's listing
+ * @param {object} [options] The readdir options
+ * @returns {string[]|Dirent[]}
+ */
+ #toMountedDirents(dirPath, result, options) {
+ if (options?.withFileTypes !== true) return result;
+ const recursive = options?.recursive === true;
+ // A mounted VFS is addressed by host paths, so, like fs, join with the
+ // host's separator; an unmounted one uses POSIX paths throughout.
+ const join = this[kMounted] ? path.join : joinPath;
+ for (let i = 0; i < result.length; i++) {
+ const dirent = result[i];
+ dirent.parentPath = dirPath;
+ if (recursive) {
+ // In recursive mode, name may contain slashes (e.g. 'a/b.txt').
+ const slashIdx = StringPrototypeLastIndexOf(dirent.name, '/');
+ if (slashIdx !== -1) {
+ const subdir = StringPrototypeSlice(dirent.name, 0, slashIdx);
+ dirent.parentPath = join(dirPath, subdir);
+ dirent.name = StringPrototypeSlice(dirent.name, slashIdx + 1);
}
}
}
-
return result;
}
@@ -386,6 +417,7 @@ class VirtualFileSystem {
*/
rmdirSync(dirPath) {
const providerPath = this.#toProviderPath(dirPath);
+ checkNotRoot(providerPath, 'rmdir', dirPath);
this[kProvider].rmdirSync(providerPath);
}
@@ -406,6 +438,8 @@ class VirtualFileSystem {
renameSync(oldPath, newPath) {
const oldProviderPath = this.#toProviderPath(oldPath);
const newProviderPath = this.#toProviderPath(newPath);
+ checkNotRoot(oldProviderPath, 'rename', oldPath);
+ checkNotRoot(newProviderPath, 'rename', newPath);
this[kProvider].renameSync(oldProviderPath, newProviderPath);
}
@@ -774,7 +808,8 @@ class VirtualFileSystem {
}
this[kProvider].readdir(this.#toProviderPath(dirPath), options)
- .then((entries) => callback(null, entries), (err) => callback(err));
+ .then((entries) => callback(null, this.#toMountedDirents(dirPath, entries, options)),
+ (err) => callback(err));
}
/**
@@ -1134,6 +1169,8 @@ class VirtualFileSystem {
const toProviderPath = (p) => this.#toProviderPath(p);
const toProviderPrefix = (p) => this.#toProviderPrefix(p);
const toMountedPath = (p) => this.#toMountedPath(p);
+ const toMountedDirents = (p, result, options) =>
+ this.#toMountedDirents(p, result, options);
return ObjectFreeze({
async readFile(filePath, options) {
@@ -1163,7 +1200,8 @@ class VirtualFileSystem {
async readdir(dirPath, options) {
const providerPath = toProviderPath(dirPath);
- return provider.readdir(providerPath, options);
+ const result = await provider.readdir(providerPath, options);
+ return toMountedDirents(dirPath, result, options);
},
async mkdir(dirPath, options) {
@@ -1174,6 +1212,7 @@ class VirtualFileSystem {
async rmdir(dirPath) {
const providerPath = toProviderPath(dirPath);
+ checkNotRoot(providerPath, 'rmdir', dirPath);
return provider.rmdir(providerPath);
},
@@ -1185,6 +1224,8 @@ class VirtualFileSystem {
async rename(oldPath, newPath) {
const oldProviderPath = toProviderPath(oldPath);
const newProviderPath = toProviderPath(newPath);
+ checkNotRoot(oldProviderPath, 'rename', oldPath);
+ checkNotRoot(newProviderPath, 'rename', newPath);
return provider.rename(oldProviderPath, newProviderPath);
},
@@ -1240,7 +1281,7 @@ class VirtualFileSystem {
for (let i = 0; i < entries.length; i++) {
await this.rm(joinPath(filePath, entries[i]), options);
}
- await provider.rmdir(toProviderPath(filePath));
+ await this.rmdir(filePath);
} else {
await provider.unlink(toProviderPath(filePath));
}
@@ -1316,5 +1357,6 @@ module.exports = {
VirtualFileSystem,
kLayerId,
kLoadLayer,
+ kReservedRoot,
normalizeMountedPath,
};
diff --git a/lib/internal/vfs/root.js b/lib/internal/vfs/root.js
new file mode 100644
index 00000000000..234506abd8d
--- /dev/null
+++ b/lib/internal/vfs/root.js
@@ -0,0 +1,213 @@
+'use strict';
+
+const {
+ ArrayPrototypePush,
+ ArrayPrototypeSort,
+ String,
+ StringPrototypeIndexOf,
+ StringPrototypeSlice,
+} = primordials;
+
+const { Dirent } = require('internal/fs/utils');
+const { VirtualProvider } = require('internal/vfs/provider');
+const { decodeOpenFlags } = require('internal/vfs/file_handle');
+const {
+ createEEXIST,
+ createEINVAL,
+ createEISDIR,
+ createENOENT,
+ createEROFS,
+} = require('internal/vfs/errors');
+const { createDirectoryStats } = require('internal/vfs/stats');
+const { isLayerId } = require('internal/vfs/router');
+const {
+ fs: {
+ UV_DIRENT_DIR,
+ },
+} = internalBinding('constants');
+
+const kRoot = 0;
+const kLayer = 1;
+
+/**
+ * Serves the reserved VFS root directory: a read-only directory holding the
+ * mount point of every active layer, named by its layer id.
+ *
+ * Only paths that no layer serves reach this provider. The dispatcher in
+ * internal/vfs/setup.js hands a path inside a layer to that layer.
+ */
+class ReservedRootProvider extends VirtualProvider {
+ #layers;
+
+ /**
+ * @param {Map<number, VirtualFileSystem>} layers The active layers
+ */
+ constructor(layers) {
+ super();
+ this.#layers = layers;
+ }
+
+ get readonly() { return true; }
+
+ /**
+ * @param {string} path A provider-relative path
+ * @param {string} syscall
+ * @returns {{ kind: number, layerId?: number }}
+ */
+ #lookup(path, syscall) {
+ if (path === '/') return { __proto__: null, kind: kRoot };
+ if (StringPrototypeIndexOf(path, '/', 1) === -1) {
+ const segment = StringPrototypeSlice(path, 1);
+ if (isLayerId(segment)) {
+ const layerId = +segment;
+ if (this.#layers.has(layerId)) {
+ return { __proto__: null, kind: kLayer, layerId };
+ }
+ }
+ }
+ throw createENOENT(syscall, path);
+ }
+
+ #directoryStats(options) {
+ return createDirectoryStats({ __proto__: null, mode: 0o555, bigint: options?.bigint });
+ }
+
+ openSync(path, flags, mode) {
+ const { writable, create } = decodeOpenFlags(flags ?? 'r');
+ if (writable || create) throw createEROFS('open', path);
+ this.#lookup(path, 'open');
+ // Everything here is a directory.
+ throw createEISDIR('open', path);
+ }
+
+ async open(path, flags, mode) {
+ return this.openSync(path, flags, mode);
+ }
+
+ statSync(path, options) {
+ this.#lookup(path, 'stat');
+ return this.#directoryStats(options);
+ }
+
+ async stat(path, options) {
+ return this.statSync(path, options);
+ }
+
+ readdirSync(path, options) {
+ const entry = this.#lookup(path, 'scandir');
+ if (entry.kind !== kRoot) {
+ // A layer's own mount point is served by the layer.
+ throw createENOENT('scandir', path);
+ }
+ const withFileTypes = options?.withFileTypes === true;
+ const recursive = options?.recursive === true;
+
+ const layerIds = [];
+ for (const layerId of this.#layers.keys()) {
+ ArrayPrototypePush(layerIds, layerId);
+ }
+ ArrayPrototypeSort(layerIds, (a, b) => a - b);
+
+ const result = [];
+ for (let i = 0; i < layerIds.length; i++) {
+ const name = String(layerIds[i]);
+ ArrayPrototypePush(result, withFileTypes ?
+ new Dirent(name, UV_DIRENT_DIR, '/') : name);
+ }
+
+ // A recursive listing descends into each layer.
+ if (recursive) {
+ const layerOptions = { __proto__: null, withFileTypes, recursive: true };
+ for (let i = 0; i < layerIds.length; i++) {
+ const prefix = `${layerIds[i]}/`;
+ const provider = this.#layers.get(layerIds[i]).provider;
+ const entries = provider.readdirSync('/', layerOptions);
+ for (let j = 0; j < entries.length; j++) {
+ if (withFileTypes) {
+ entries[j].name = prefix + entries[j].name;
+ ArrayPrototypePush(result, entries[j]);
+ } else {
+ ArrayPrototypePush(result, prefix + entries[j]);
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ async readdir(path, options) {
+ return this.readdirSync(path, options);
+ }
+
+ readlinkSync(path, options) {
+ this.#lookup(path, 'readlink');
+ // Nothing here is a symbolic link.
+ throw createEINVAL('readlink', path);
+ }
+
+ async readlink(path, options) {
+ return this.readlinkSync(path, options);
+ }
+
+ realpathSync(path, options) {
+ this.#lookup(path, 'realpath');
+ return path;
+ }
+
+ async realpath(path, options) {
+ return this.realpathSync(path, options);
+ }
+
+ mkdirSync(path, options) {
+ // Like mkdir(2) on a read-only file system, an existing entry is
+ // reported as such, which `recursive` accepts.
+ try {
+ this.#lookup(path, 'mkdir');
+ } catch {
+ throw createEROFS('mkdir', path);
+ }
+ if (options?.recursive === true) return undefined;
+ throw createEEXIST('mkdir', path);
+ }
+
+ async mkdir(path, options) {
+ return this.mkdirSync(path, options);
+ }
+
+ rmdirSync(path) {
+ this.#lookup(path, 'rmdir');
+ throw createEROFS('rmdir', path);
+ }
+
+ unlinkSync(path) {
+ this.#lookup(path, 'unlink');
+ throw createEROFS('unlink', path);
+ }
+
+ chmodSync(path, mode) {
+ this.#lookup(path, 'chmod');
+ throw createEROFS('chmod', path);
+ }
+
+ lchmodSync(path, mode) {
+ this.chmodSync(path, mode);
+ }
+
+ chownSync(path, uid, gid) {
+ this.#lookup(path, 'chown');
+ throw createEROFS('chown', path);
+ }
+
+ utimesSync(path, atime, mtime) {
+ this.#lookup(path, 'utime');
+ throw createEROFS('utime', path);
+ }
+
+ lutimesSync(path, atime, mtime) {
+ this.utimesSync(path, atime, mtime);
+ }
+}
+
+module.exports = {
+ ReservedRootProvider,
+};
diff --git a/lib/internal/vfs/router.js b/lib/internal/vfs/router.js
index 53247cbc670..7f037db869d 100644
--- a/lib/internal/vfs/router.js
+++ b/lib/internal/vfs/router.js
@@ -2,6 +2,7 @@
const {
ArrayPrototypeJoin,
+ NumberIsInteger,
StringPrototypeCharCodeAt,
StringPrototypeSplit,
} = primordials;
@@ -60,6 +61,18 @@ function getLayerIdFromPath(normalizedPath) {
return id;
}
+/**
+ * Returns true if `segment`, a single segment below the VFS root, is
+ * spelled the way a layer id is: a non-negative integer in its canonical
+ * form.
+ * @param {string} segment
+ * @returns {boolean}
+ */
+function isLayerId(segment) {
+ const n = +segment;
+ return NumberIsInteger(n) && n >= 0 && `${n}` === segment;
+}
+
// POSIX-style relative path for the provider. `path.relative()` handles
// Windows backslashes; we re-join with forward slashes.
function getRelativePath(normalizedPath, mountPoint) {
@@ -76,4 +89,6 @@ module.exports = {
getLayerRoot,
getNormalizedVfsRoot,
getRelativePath,
+ getVfsRoot,
+ isLayerId,
};
diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js
index f69833eb53a..a5f1b42659b 100644
--- a/lib/internal/vfs/setup.js
+++ b/lib/internal/vfs/setup.js
@@ -24,12 +24,18 @@ const {
ERR_MODULE_NOT_FOUND,
},
} = require('internal/errors');
-const { createENOENT, createEXDEV } = require('internal/vfs/errors');
-const { kLayerId, normalizeMountedPath } = require('internal/vfs/file_system');
+const { createENOENT, createEROFS, createEXDEV } = require('internal/vfs/errors');
+const {
+ VirtualFileSystem,
+ kLayerId,
+ kReservedRoot,
+ normalizeMountedPath,
+} = require('internal/vfs/file_system');
const {
getLayerIdFromPath,
getNormalizedVfsRoot,
} = require('internal/vfs/router');
+const { ReservedRootProvider } = require('internal/vfs/root');
const { getVirtualFd, closeVirtualFd, createVfsFileHandle } = require('internal/vfs/fd');
const { assertEncoding, setVfsHandlers } = require('internal/fs/utils');
const permission = require('internal/process/permission');
@@ -84,10 +90,13 @@ function writeFileSyncFd(fd, data, options) {
}
const activeVFSLayers = new SafeMap();
+// Serves the reserved root directory itself, while any layer is mounted.
+let rootVFS = null;
let hooksInstalled = false;
let vfsHandlerObj;
// Lazy: os.devNull may not be available at snapshot time.
+let normalizedVfsRoot = null;
let normalizedVfsRootPrefix = null;
function registerVFS(vfs) {
@@ -117,14 +126,15 @@ function deregisterVFS(vfs) {
/**
* Resolves a path string to the reserved VFS root, or null for a path
- * outside it. Ownership is decidable from the path alone: all mount
- * points live under the reserved `${os.devNull}/vfs/<id>` namespace, so
- * a single prefix comparison rejects every real-file-system path and a
- * map lookup finds the owning layer. The normalized path is returned
+ * other than the root itself and those under it. Ownership is decidable
+ * from the path alone: all mount points live under the reserved
+ * `${os.devNull}/vfs/<id>` namespace, so a single prefix comparison
+ * rejects every real-file-system path and a map lookup finds the owning
+ * layer. The normalized path is returned
* alongside the layer so downstream helpers can skip renormalization.
*
- * A path under the root that no active layer owns comes back with
- * `vfs: null` rather than as `null`, because the two cases must not be
+ * The root itself, and a path under it that no active layer owns, come
+ * back with `vfs: null` rather than as `null`, because the two cases must not be
* treated alike by the module loader. The loader manufactures such
* paths itself: resolving a mount point as a directory first probes the
* sibling names `<mount>.js`, `<mount>.json`, ..., and a package.json
@@ -139,7 +149,8 @@ function deregisterVFS(vfs) {
*/
function findVFSOrRoot(inputPath) {
const normalized = normalizeMountedPath(inputPath);
- if (!StringPrototypeStartsWith(normalized, normalizedVfsRootPrefix)) {
+ if (normalized !== normalizedVfsRoot &&
+ !StringPrototypeStartsWith(normalized, normalizedVfsRootPrefix)) {
return null;
}
const layerId = getLayerIdFromPath(normalized);
@@ -151,13 +162,18 @@ function findVFSOrRoot(inputPath) {
}
/**
- * Resolves a path string to the active VFS that owns it, or null.
+ * Resolves a path string to the VFS that serves it for the fs functions,
+ * or null for a path outside the reserved VFS root. Unlike the loader, the
+ * fs functions see the root as a directory, so a path under the root that
+ * no layer serves is the root file system's to answer.
* @param {string} inputPath
* @returns {{ vfs: object, normalized: string }|null}
*/
function findVFS(inputPath) {
const r = findVFSOrRoot(inputPath);
- return r === null || r.vfs === null ? null : r;
+ if (r === null) return null;
+ if (r.vfs === null) r.vfs = rootVFS;
+ return r;
}
/**
@@ -356,6 +372,19 @@ function checkSameVFS(srcPath, destPath, syscall, srcVfs) {
}
}
+// Nothing in the root directory can be removed, and a recursive removal of
+// it would walk into the layers' mount points, which only the layers serve,
+// so it is refused before it starts.
+function rmRootSync(path, options) {
+ try {
+ rootVFS.lstatSync(path);
+ } catch (err) {
+ if (options?.force === true && err?.code === 'ENOENT') return;
+ throw err;
+ }
+ throw createEROFS('rm', path);
+}
+
function createVfsHandlers() {
return {
__proto__: null,
@@ -456,7 +485,12 @@ function createVfsHandlers() {
mkdirSync: (path, options) =>
vfsOp(path, (vfs, n) => ({ result: vfs.mkdirSync(n, options) })),
rmdirSync: (path) => vfsOpVoid(path, (vfs, n) => vfs.rmdirSync(n)),
- rmSync: (path, options) => vfsOpVoid(path, (vfs, n) => vfs.rmSync(n, options)),
+ rmSync(path, options) {
+ return vfsOpVoid(path, (vfs, n) => {
+ if (vfs === rootVFS) rmRootSync(n, options);
+ else vfs.rmSync(n, options);
+ });
+ },
unlinkSync: (path) => vfsOpVoid(path, (vfs, n) => vfs.unlinkSync(n)),
renameSync(oldPath, newPath) {
return vfsOpVoid(oldPath, (vfs, n) => {
@@ -785,7 +819,13 @@ function createVfsHandlers() {
vfs.promises.mkdir(n, options).then((result) => ({ __proto__: null, result })));
},
rmdir: (path) => vfsOp(path, (vfs, n) => vfs.promises.rmdir(n).then(() => true)),
- rm: (path, options) => vfsOp(path, (vfs, n) => vfs.promises.rm(n, options).then(() => true)),
+ rm(path, options) {
+ return vfsOp(path, async (vfs, n) => {
+ if (vfs === rootVFS) rmRootSync(n, options);
+ else await vfs.promises.rm(n, options);
+ return true;
+ });
+ },
unlink: (path) => vfsOp(path, (vfs, n) => vfs.promises.unlink(n).then(() => true)),
rename(oldPath, newPath) {
return vfsOp(oldPath, (vfs, n) => {
@@ -1064,7 +1104,11 @@ function readVirtualBinary(pathStr) {
function installHooks() {
if (hooksInstalled) return;
debug('install hooks');
- normalizedVfsRootPrefix = getNormalizedVfsRoot() + sep;
+ normalizedVfsRoot = getNormalizedVfsRoot();
+ normalizedVfsRootPrefix = normalizedVfsRoot + sep;
+ rootVFS ??= new VirtualFileSystem(
+ new ReservedRootProvider(activeVFSLayers),
+ { __proto__: null, emitExperimentalWarning: false, [kReservedRoot]: true });
installModuleLoaderOverrides();
installAddonLoader();
const { setVfsLibraryReader } = require('internal/ffi/vfs');
diff --git a/lib/vfs.js b/lib/vfs.js
index 47e48b3d62c..be47f408adf 100644
--- a/lib/vfs.js
+++ b/lib/vfs.js
@@ -10,6 +10,7 @@ const { MemoryProvider } = require('internal/vfs/providers/memory');
const { RealFSProvider } = require('internal/vfs/providers/real');
const { ZipProvider } = require('internal/vfs/providers/ziparchive');
const { registerProvider } = require('internal/vfs/provider_registry');
+const { getVfsRoot } = require('internal/vfs/router');
/**
* Creates a new VirtualFileSystem instance.
@@ -30,8 +31,18 @@ function create(provider, options) {
return new VirtualFileSystem(provider, options);
}
+/**
+ * Returns the directory that holds the mount points of every mounted virtual
+ * file system.
+ * @returns {string} The absolute path of the reserved root directory
+ */
+function vfsBase() {
+ return getVfsRoot();
+}
+
module.exports = {
create,
+ vfsBase,
registerProvider,
VirtualFileSystem,
VirtualProvider,
diff --git a/test/parallel/test-vfs-reserved-root.js b/test/parallel/test-vfs-reserved-root.js
new file mode 100644
index 00000000000..9de39fb8ff9
--- /dev/null
+++ b/test/parallel/test-vfs-reserved-root.js
@@ -0,0 +1,208 @@
+// Flags: --experimental-vfs
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const vfs = require('node:vfs');
+
+// The reserved root is where every mount point lives, and vfs.vfsBase() is
+// how a program finds it without spelling the path out.
+const root = vfs.vfsBase();
+assert.strictEqual(root, path.join(os.devNull, 'vfs'));
+assert.strictEqual(vfs.vfsBase(), root);
+
+const a = vfs.create();
+a.mkdirSync('/dir');
+a.writeFileSync('/dir/file.txt', 'from a');
+a.writeFileSync('/mod.js', 'module.exports = __filename;');
+const b = vfs.create();
+b.writeFileSync('/file.txt', 'from b');
+const empty = vfs.create();
+
+const mountA = a.mount();
+const mountB = b.mount();
+const mountEmpty = empty.mount();
+assert.strictEqual(path.dirname(mountA), root);
+const idA = path.basename(mountA);
+const idB = path.basename(mountB);
+const idEmpty = path.basename(mountEmpty);
+const layerIds = [idA, idB, idEmpty].sort((x, y) => x - y);
+
+// The root lists every layer by id.
+{
+ assert.deepStrictEqual(fs.readdirSync(root), layerIds);
+ assert.deepStrictEqual(fs.readdirSync(`${root}${path.sep}`), layerIds);
+ assert.deepStrictEqual(fs.readdirSync(root, { encoding: 'buffer' }),
+ layerIds.map((name) => Buffer.from(name)));
+ for (const id of fs.readdirSync(root)) {
+ assert.ok([mountA, mountB, mountEmpty].includes(path.join(root, id)), id);
+ }
+
+ const dirents = fs.readdirSync(root, { withFileTypes: true });
+ assert.deepStrictEqual(dirents.map((d) => d.name), layerIds);
+ for (const dirent of dirents) {
+ assert.strictEqual(dirent.parentPath, root);
+ assert.ok(dirent.isDirectory(), dirent.name);
+ }
+
+ const dir = fs.opendirSync(root);
+ const names = [];
+ let dirent;
+ while ((dirent = dir.readSync()) !== null) names.push(dirent.name);
+ dir.closeSync();
+ assert.deepStrictEqual(names, layerIds);
+
+ fs.readdir(root, common.mustSucceed((result) => {
+ assert.deepStrictEqual(result, layerIds);
+ }));
+ fs.promises.readdir(root).then(common.mustCall((result) => {
+ assert.deepStrictEqual(result, layerIds);
+ }));
+}
+
+// A recursive listing descends into the layers.
+{
+ const expected = [
+ ...layerIds,
+ `${idA}/dir`, `${idA}/mod.js`, `${idA}/dir/file.txt`,
+ `${idB}/file.txt`,
+ ];
+ assert.deepStrictEqual(fs.readdirSync(root, { recursive: true }).sort(),
+ expected.sort());
+
+ // Every API names each entry's directory as a host path.
+ const options = { recursive: true, withFileTypes: true };
+ const check = common.mustCall((dirents) => {
+ const file = dirents.find((d) => d.name === 'file.txt' &&
+ d.parentPath === path.join(mountA, 'dir'));
+ assert.ok(file?.isFile());
+ const layer = dirents.find((d) => d.name === idA);
+ assert.strictEqual(layer?.parentPath, root);
+ }, 3);
+ check(fs.readdirSync(root, options));
+ fs.readdir(root, options, common.mustSucceed(check));
+ fs.promises.readdir(root, options).then(common.mustCall((dirents) => {
+ check(dirents);
+ }));
+}
+
+// The root is a directory, and so is each mount point in it.
+{
+ assert.ok(fs.statSync(root).isDirectory());
+ assert.ok(fs.lstatSync(root).isDirectory());
+ assert.ok(fs.statSync(root, { bigint: true }).isDirectory());
+ assert.ok(fs.existsSync(root));
+ fs.accessSync(root, fs.constants.R_OK);
+ assert.strictEqual(fs.realpathSync(root), root);
+ assert.strictEqual(fs.realpathSync(mountA), mountA);
+ assert.throws(() => fs.readlinkSync(root), { code: 'EINVAL' });
+ assert.ok(fs.lstatSync(mountA).isDirectory());
+
+ fs.stat(root, common.mustSucceed((stats) => {
+ assert.ok(stats.isDirectory());
+ }));
+ fs.realpath(root, common.mustSucceed((real) => {
+ assert.strictEqual(real, root);
+ }));
+ fs.promises.lstat(root).then(common.mustCall((stats) => {
+ assert.ok(stats.isDirectory());
+ }));
+}
+
+// Anything else under the root does not exist.
+{
+ for (const missing of ['missing', 'missing/deeper', `${idA}0`, `0${idA}`,
+ `${idA}.js`, '-1']) {
+ const p = path.join(root, missing);
+ assert.strictEqual(fs.existsSync(p), false, p);
+ assert.throws(() => fs.statSync(p), { code: 'ENOENT' }, p);
+ assert.throws(() => fs.lstatSync(p), { code: 'ENOENT' }, p);
+ assert.throws(() => fs.readdirSync(p), { code: 'ENOENT' }, p);
+ assert.throws(() => fs.readFileSync(p), { code: 'ENOENT' }, p);
+ assert.throws(() => fs.realpathSync(p), { code: 'ENOENT' }, p);
+ assert.strictEqual(fs.statSync(p, { throwIfNoEntry: false }), undefined);
+ }
+}
+
+// The root is read-only; the file systems in it are not.
+{
+ const erofs = { code: 'EROFS' };
+ const newPath = path.join(root, 'new');
+ assert.throws(() => fs.writeFileSync(newPath, ''), erofs);
+ assert.throws(() => fs.mkdirSync(newPath), erofs);
+ assert.throws(() => fs.mkdirSync(path.join(newPath, 'deeper'), { recursive: true }), erofs);
+ assert.throws(() => fs.mkdtempSync(path.join(root, 'tmp-')), erofs);
+ assert.throws(() => fs.symlinkSync(idA, newPath), erofs);
+ assert.throws(() => fs.openSync(newPath, 'w'), erofs);
+ assert.throws(() => fs.openSync(root, 'r+'), erofs);
+ assert.throws(() => fs.openSync(root, 'r'), { code: 'EISDIR' });
+ assert.throws(() => fs.rmSync(root, { recursive: true, force: true }), erofs);
+ // Like the mount points in it, the root cannot be removed.
+ assert.throws(() => fs.rmdirSync(root), { code: 'EBUSY' });
+ assert.throws(() => fs.chmodSync(root, 0o777), erofs);
+ assert.throws(() => fs.utimesSync(root, 0, 0), erofs);
+ // Only an existing entry is reported as read-only.
+ assert.throws(() => fs.unlinkSync(newPath), { code: 'ENOENT' });
+ fs.rmSync(newPath, { force: true });
+ // Creating what exists fails as it would anywhere.
+ assert.throws(() => fs.mkdirSync(root), { code: 'EEXIST' });
+ fs.mkdirSync(root, { recursive: true });
+ // A layer and the root are different file systems.
+ assert.throws(() => fs.renameSync(path.join(mountA, 'mod.js'), newPath),
+ { code: 'EXDEV' });
+
+ // Nothing was removed, and the layers stay writable.
+ assert.strictEqual(fs.readFileSync(path.join(mountA, 'dir', 'file.txt'), 'utf8'),
+ 'from a');
+ fs.writeFileSync(path.join(mountB, 'new.txt'), 'written');
+ assert.strictEqual(fs.readFileSync(path.join(mountB, 'new.txt'), 'utf8'), 'written');
+
+ fs.rm(root, { recursive: true }, common.expectsError(erofs));
+ assert.rejects(fs.promises.rm(root, { recursive: true }), erofs).then(common.mustCall());
+ assert.rejects(fs.promises.mkdir(newPath), erofs).then(common.mustCall());
+}
+
+// A mount point cannot be removed or renamed, nor replaced by a rename.
+{
+ const busy = { code: 'EBUSY' };
+ const c = vfs.create();
+ c.mkdirSync('/dir');
+ c.writeFileSync('/dir/file.txt', 'data');
+ const mountC = c.mount();
+ assert.throws(() => fs.rmdirSync(mountC), busy);
+ assert.throws(() => fs.renameSync(mountC, path.join(mountC, 'moved')), busy);
+ assert.throws(() => fs.renameSync(path.join(mountC, 'dir'), mountC), busy);
+ assert.throws(() => c.rmdirSync(mountC), busy);
+ // A recursive removal empties the file system, then fails on its root.
+ assert.throws(() => fs.rmSync(mountC, { recursive: true }), busy);
+ assert.deepStrictEqual(fs.readdirSync(mountC), []);
+ assert.throws(() => fs.rmdirSync(mountC), busy);
+ assert.ok(fs.statSync(mountC).isDirectory());
+ assert.strictEqual(c.mounted, true);
+
+ fs.rmdir(mountC, common.expectsError(busy));
+ assert.rejects(fs.promises.rmdir(mountC), busy).then(common.mustCall());
+ assert.rejects(fs.promises.rename(mountC, path.join(mountC, 'moved')), busy)
+ .then(common.mustCall());
+ assert.rejects(fs.promises.rm(mountC, { recursive: true }), busy)
+ .then(() => c.unmount()).then(common.mustCall());
+
+ // The same holds for the root of a file system that is not mounted.
+ const unmounted = vfs.create();
+ assert.throws(() => unmounted.rmdirSync('/'), busy);
+ assert.throws(() => unmounted.renameSync('/', '/moved'), busy);
+}
+
+// Unmounting removes a layer from the root; with nothing mounted, there is
+// no root directory.
+process.on('beforeExit', common.mustCall(() => {
+ a.unmount();
+ b.unmount();
+ assert.deepStrictEqual(fs.readdirSync(root), [idEmpty]);
+ assert.strictEqual(fs.existsSync(mountA), false);
+ empty.unmount();
+ assert.strictEqual(fs.existsSync(root), false);
+}));