Skip to content

Commit a2cd24b

Browse files
committed
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 its layer id, 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. 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. Reserve layer 0 for the file system --vfs-load mounts, and number the others from 1. That source is then at the same reserved mount point in every thread, whatever else a thread mounts and wherever --vfs-load is written among the other mounts, so a path into it stays valid in a worker - including a worker created with its own execArgv, which inherits none of the parent's options and has to be given --vfs-load again. A worker still does not run that entry point, but it now has to recognize which source it belongs to in order to mount it at that layer. 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. Add vfs.vfsBase(), which returns that directory, so a program can list what is mounted without spelling out `path.join(os.devNull, 'vfs')`. The note under vfs.mount() said the path scheme must not be relied on, which read as a contradiction of the root being listable; it now says where a mount point comes from, and that only the name within the root is assigned at runtime. Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
1 parent bca9bbe commit a2cd24b

13 files changed

Lines changed: 761 additions & 72 deletions

File tree

‎doc/api/cli.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3812,6 +3812,11 @@ from an earlier `--vfs-mount` of the same source.
38123812
In worker threads `--vfs-load` mounts but does not load: a worker inherits the
38133813
same mounts, in the same order, and runs its own entry point.
38143814

3815+
The source `--vfs-load` names is mounted at the same reserved mount point in
3816+
every thread, whatever else that thread mounts, so a path into it stays valid
3817+
in a worker - including one created with its own `execArgv`, which does not
3818+
inherit the parent's options and has to be given `--vfs-load` again.
3819+
38153820
`--vfs-load` is not permitted in [`NODE_OPTIONS`][]: which entry point runs is
38163821
the command line's decision, and the environment must not be able to redirect
38173822
it.

‎doc/api/vfs.md‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,29 @@ $ node --experimental-vfs --require ./provider.js \
152152
--vfs-load archive.customfmt
153153
```
154154

155+
## `vfs.vfsBase()`
156+
157+
<!-- YAML
158+
added: REPLACEME
159+
-->
160+
161+
* Returns: {string} The absolute path of the [reserved root directory][].
162+
163+
Returns the directory that holds the mount points of every mounted virtual file
164+
system, which is `path.join(os.devNull, 'vfs')`. Reading it lists what is
165+
mounted; see [The reserved root directory][reserved root directory].
166+
167+
```cjs
168+
const vfs = require('node:vfs');
169+
const fs = require('node:fs');
170+
171+
const myVfs = vfs.create();
172+
const mountPoint = myVfs.mount();
173+
174+
fs.readdirSync(vfs.vfsBase()); // e.g. [ '1' ]
175+
mountPoint.startsWith(vfs.vfsBase()); // true
176+
```
177+
155178
## Class: `VirtualFileSystem`
156179

157180
<!-- YAML
@@ -187,9 +210,11 @@ After mounting, files in the VFS can be accessed through the
187210
using paths under the returned mount point.
188211

189212
Mount points always live inside a reserved namespace that cannot have child file system entries,
190-
so virtual paths never conflate with (or shadow) real paths. The virtual path scheme is subject to
191-
change and users should not manually construct them based on assumptions. Instead, obtain
192-
them from what `vfs.mount()` returns or `vfs.mountPoint`.
213+
so virtual paths never conflate with (or shadow) real paths. A mount point is obtained from what
214+
`vfs.mount()` returns or from [`vfs.mountPoint`][], and the mount points of all mounted file
215+
systems can be listed by reading the [reserved root directory][], whose path [`vfs.vfsBase()`][]
216+
returns. The name of a mount point within that directory is assigned at runtime, so it is not
217+
something to construct or hard-code.
193218

194219
```cjs
195220
const vfs = require('node:vfs');
@@ -203,6 +228,11 @@ const mountPoint = myVfs.mount();
203228
fs.readFileSync(`${mountPoint}/data.txt`, 'utf8'); // 'Hello'
204229
```
205230

231+
Like any mount point, the mount point cannot be removed or renamed, nor
232+
replaced by renaming something else onto it: [`fs.rmdir()`][] and
233+
[`fs.rename()`][] fail with `EBUSY`. A recursive [`fs.rm()`][] of the mount
234+
point empties the file system before failing the same way.
235+
206236
Each `VirtualFileSystem` instance may be mounted at most once at a
207237
time. Attempting to mount an already-mounted instance throws
208238
`ERR_INVALID_STATE`. Because each instance mounts inside its own
@@ -380,6 +410,32 @@ The promise namespace mirrors `fs.promises` and includes `readFile`,
380410
`access`, `rm`, `truncate`, `link`, `mkdtemp`, `chmod`, `chown`, `lchown`,
381411
`utimes`, `lutimes`, `open`, `lchmod`, and `watch`.
382412

413+
## The reserved root directory
414+
415+
While any virtual file system is mounted, the directory that holds the mount
416+
points can be read through [`node:fs`][]. [`vfs.vfsBase()`][] returns its path,
417+
`path.join(os.devNull, 'vfs')`. It contains a directory for every mounted file
418+
system, named like the last segment of its [`vfs.mountPoint`][].
419+
420+
```cjs
421+
const vfs = require('node:vfs');
422+
const fs = require('node:fs');
423+
const path = require('node:path');
424+
425+
const root = vfs.vfsBase();
426+
const assets = vfs.create();
427+
assets.writeFileSync('/logo.svg', '<svg/>');
428+
const mountPoint = assets.mount();
429+
430+
fs.readdirSync(root); // e.g. [ '1' ]
431+
path.join(root, fs.readdirSync(root)[0]) === mountPoint; // true
432+
fs.readdirSync(root, { recursive: true }); // e.g. [ '1', '1/logo.svg' ]
433+
```
434+
435+
The root directory itself is read-only. Creating, removing, or changing its
436+
entries fails with `EROFS`, while the file systems its entries lead to can be
437+
written to as usual. When nothing is mounted, the root directory does not exist.
438+
383439
## Module loader integration
384440

385441
Once a `VirtualFileSystem` is mounted, paths under the mount point
@@ -711,6 +767,9 @@ fields use synthetic but stable values:
711767
[`ffi.dlopen()`]: ffi.md#ffidlopenpath-definitions
712768
[`fs.BigIntStats`]: fs.md#class-fsstats
713769
[`fs.Stats`]: fs.md#class-fsstats
770+
[`fs.rename()`]: fs.md#fsrenameoldpath-newpath-callback
771+
[`fs.rm()`]: fs.md#fsrmpath-options-callback
772+
[`fs.rmdir()`]: fs.md#fsrmdirpath-options-callback
714773
[`import.meta.resolve()`]: esm.md#importmetaresolvespecifier
715774
[`new ffi.DynamicLibrary()`]: ffi.md#new-dynamiclibrarypath
716775
[`node:fs`]: fs.md
@@ -721,8 +780,10 @@ fields use synthetic but stable values:
721780
[`vfs.mountPointURL`]: #vfsmountpointurl
722781
[`vfs.mountPoint`]: #vfsmountpoint
723782
[`vfs.unmount()`]: #vfsunmount
783+
[`vfs.vfsBase()`]: #vfsvfsbase
724784
[`zipFile.writable`]: zlib.md#zipfilewritable
725785
[`zlib.ZipBuffer`]: zlib.md#class-zlibzipbuffer
726786
[`zlib.ZipFile`]: zlib.md#class-zlibzipfile
727787
[loading from `node_modules` folders]: modules.md#loading-from-node_modules-folders
788+
[reserved root directory]: #the-reserved-root-directory
728789
[the global folders]: modules.md#loading-from-the-global-folders

‎doc/node.1‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,6 +1901,10 @@ The entry point then comes from the mount \fB--vfs-load\fR itself contributed, n
19011901
from an earlier \fB--vfs-mount\fR of the same source.
19021902
In worker threads \fB--vfs-load\fR mounts but does not load: a worker inherits the
19031903
same mounts, in the same order, and runs its own entry point.
1904+
The source \fB--vfs-load\fR names is mounted at the same reserved mount point in
1905+
every thread, whatever else that thread mounts, so a path into it stays valid
1906+
in a worker - including one created with its own \fBexecArgv\fR, which does not
1907+
inherit the parent's options and has to be given \fB--vfs-load\fR again.
19041908
\fB--vfs-load\fR is not permitted in \fBNODE_OPTIONS\fR: which entry point runs is
19051909
the command line's decision, and the environment must not be able to redirect
19061910
it.

‎lib/internal/process/pre_execution.js‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,11 @@ let vfsLoadRoot;
223223
// the command line's own node options, in order. NODE_OPTIONS may add mounts
224224
// but not a --vfs-load, so anything it contributed sits ahead of these.
225225
// Returns -1 when no --vfs-load was given.
226+
//
227+
// A worker does not run the --vfs-load entry (see node_worker.cc), but it must
228+
// still recognize which source that entry is, so as to give it the reserved
229+
// layer its mount point has on every other thread.
226230
function getVfsLoadIndex(mountCount) {
227-
if (!getOptionValue('[vfs_load_set]')) return -1;
228-
229231
const execArgv = process.execArgv;
230232
let seen = 0;
231233
let found = -1;
@@ -267,12 +269,13 @@ function finishVfsMounts() {
267269
const fs = require('fs');
268270
const path = require('path');
269271
const { selectProvider } = require('internal/vfs/provider_registry');
270-
const { VirtualFileSystem } = require('internal/vfs/file_system');
272+
const { VirtualFileSystem, kLoadLayer } = require('internal/vfs/file_system');
271273

272-
// --vfs-load is forced off in workers (see node_worker.cc), so this records a
273-
// load root only on the main thread; a worker re-mounts the same sources in
274-
// the same order (the reserved paths line up) but runs its own entry.
275274
const loadIndex = getVfsLoadIndex(entries.length);
275+
// [vfs_load_set] is forced off in workers (see node_worker.cc), so a worker
276+
// mounts every source, the --vfs-load one at its reserved layer, and runs its
277+
// own entry rather than the mount's.
278+
const loads = getOptionValue('[vfs_load_set]');
276279
for (let i = 0; i < entries.length; i++) {
277280
const resolvedSource = path.resolve(entries[i]);
278281
let stats;
@@ -288,7 +291,11 @@ function finishVfsMounts() {
288291
if (provider === null) {
289292
throw new ERR_VFS_INVALID_TARGET(resolvedSource);
290293
}
291-
const vfs = new VirtualFileSystem(provider, { emitExperimentalWarning: false });
294+
const vfs = new VirtualFileSystem(provider, {
295+
__proto__: null,
296+
emitExperimentalWarning: false,
297+
[kLoadLayer]: i === loadIndex,
298+
});
292299
const mountPoint = vfs.mount();
293300
// The mount --vfs-load contributed is what the entry is require()d from;
294301
// process.argv[1] names the real source instead, since the reserved mount
@@ -297,7 +304,7 @@ function finishVfsMounts() {
297304
// The source is spliced in rather than assigned over argv[1]: the entry
298305
// comes from the mount, so nothing was consumed as an entry point and the
299306
// first positional argument is the program's own. Overwriting would drop it.
300-
if (i === loadIndex) {
307+
if (i === loadIndex && loads) {
301308
vfsLoadRoot = mountPoint;
302309
ArrayPrototypeSplice(process.argv, 1, 0, resolvedSource);
303310
}

‎lib/internal/vfs/errors.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const {
1919
UV_EINVAL,
2020
UV_ELOOP,
2121
UV_EACCES,
22+
UV_EBUSY,
2223
UV_EXDEV,
2324
} = internalBinding('uv');
2425

@@ -180,6 +181,16 @@ function createEACCES(syscall, path) {
180181
return err;
181182
}
182183

184+
function createEBUSY(syscall, path) {
185+
const err = new UVException({
186+
errno: UV_EBUSY,
187+
syscall,
188+
path,
189+
});
190+
ErrorCaptureStackTrace(err, createEBUSY);
191+
return err;
192+
}
193+
183194
function createEXDEV(syscall, path) {
184195
const err = new UVException({
185196
errno: UV_EXDEV,
@@ -201,5 +212,6 @@ module.exports = {
201212
createEINVAL,
202213
createELOOP,
203214
createEACCES,
215+
createEBUSY,
204216
createEXDEV,
205217
};

0 commit comments

Comments
 (0)