Skip to content

Commit 1f5af76

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 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>
1 parent 44ff2db commit 1f5af76

8 files changed

Lines changed: 645 additions & 40 deletions

File tree

‎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()); // The name of every mount point in it
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+
const name = path.basename(mountPoint);
431+
fs.readdirSync(root); // [ name ]
432+
fs.readdirSync(root, { recursive: true }); // [ name, `${name}/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

‎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
};

‎lib/internal/vfs/file_system.js‎

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
const {
44
MathRandom,
55
ObjectFreeze,
6+
StringPrototypeLastIndexOf,
7+
StringPrototypeSlice,
68
StringPrototypeStartsWith,
79
Symbol,
810
SymbolDispose,
@@ -21,6 +23,7 @@ const { join: joinPath } = pathPosix;
2123
const {
2224
getLayerRoot,
2325
getRelativePath,
26+
getVfsRoot,
2427
} = require('internal/vfs/router');
2528
const {
2629
openVirtualFd,
@@ -30,6 +33,7 @@ const {
3033
const {
3134
createENOENT,
3235
createEBADF,
36+
createEBUSY,
3337
createEISDIR,
3438
} = require('internal/vfs/errors');
3539
const { VirtualReadStream, VirtualWriteStream } = require('internal/vfs/streams');
@@ -47,7 +51,7 @@ const kNormalizedMountPoint = Symbol('kNormalizedMountPoint');
4751
const kMounted = Symbol('kMounted');
4852
const kPromises = Symbol('kPromises');
4953
const kLayerId = Symbol('kLayerId');
50-
54+
const kReservedRoot = Symbol('kReservedRoot');
5155
let nextLayerId = 0;
5256

5357
/**
@@ -74,6 +78,12 @@ function randomSuffix() {
7478
return suffix;
7579
}
7680

81+
// The root of a file system is its mount point, and like any mount point it
82+
// cannot be removed or renamed, nor replaced by a rename.
83+
function checkNotRoot(providerPath, syscall, path) {
84+
if (providerPath === '/') throw createEBUSY(syscall, path);
85+
}
86+
7787
let registerVFS;
7888
let deregisterVFS;
7989

@@ -116,10 +126,20 @@ class VirtualFileSystem {
116126
}
117127

118128
this[kProvider] = provider ?? new MemoryProvider();
129+
this[kPromises] = null;
130+
if (options[kReservedRoot] === true) {
131+
// Serves the reserved root directory itself. It is not a layer, so it
132+
// takes no layer id and leaves the numbering of real mounts alone.
133+
const root = getVfsRoot();
134+
this[kMountPoint] = root;
135+
this[kNormalizedMountPoint] = normalizeMountedPath(root);
136+
this[kMounted] = true;
137+
this[kLayerId] = -1;
138+
return;
139+
}
119140
this[kMountPoint] = null;
120141
this[kNormalizedMountPoint] = null;
121142
this[kMounted] = false;
122-
this[kPromises] = null;
123143
this[kLayerId] = nextLayerId++;
124144
}
125145

@@ -254,6 +274,8 @@ class VirtualFileSystem {
254274
*/
255275
#toMountedPath(providerPath) {
256276
if (this[kMounted] && this[kMountPoint]) {
277+
// path.join() would keep the trailing separator of the provider root.
278+
if (providerPath === '/') return this[kMountPoint];
257279
return path.join(this[kMountPoint], providerPath);
258280
}
259281
return providerPath;
@@ -337,28 +359,36 @@ class VirtualFileSystem {
337359
readdirSync(dirPath, options) {
338360
const providerPath = this.#toProviderPath(dirPath);
339361
const result = this[kProvider].readdirSync(providerPath, options);
362+
return this.#toMountedDirents(dirPath, result, options);
363+
}
340364

341-
// Rewrite Dirent parentPath from provider-relative to VFS path.
342-
if (options?.withFileTypes === true) {
343-
const recursive = options?.recursive === true;
344-
for (let i = 0; i < result.length; i++) {
345-
const dirent = result[i];
346-
if (recursive) {
347-
// In recursive mode, name may contain slashes (e.g. 'a/b.txt').
348-
const slashIdx = dirent.name.lastIndexOf('/');
349-
if (slashIdx !== -1) {
350-
const subdir = dirent.name.slice(0, slashIdx);
351-
dirent.parentPath = joinPath(dirPath, subdir);
352-
dirent.name = dirent.name.slice(slashIdx + 1);
353-
} else {
354-
dirent.parentPath = dirPath;
355-
}
356-
} else {
357-
dirent.parentPath = dirPath;
365+
/**
366+
* Rewrites the Dirents of a listing of `dirPath` from provider-relative to
367+
* VFS paths, so that each `parentPath` is the directory the entry is in.
368+
* @param {string} dirPath The listed directory, as given by the caller
369+
* @param {string[]|Dirent[]} result The provider's listing
370+
* @param {object} [options] The readdir options
371+
* @returns {string[]|Dirent[]}
372+
*/
373+
#toMountedDirents(dirPath, result, options) {
374+
if (options?.withFileTypes !== true) return result;
375+
const recursive = options?.recursive === true;
376+
// A mounted VFS is addressed by host paths, so, like fs, join with the
377+
// host's separator; an unmounted one uses POSIX paths throughout.
378+
const join = this[kMounted] ? path.join : joinPath;
379+
for (let i = 0; i < result.length; i++) {
380+
const dirent = result[i];
381+
dirent.parentPath = dirPath;
382+
if (recursive) {
383+
// In recursive mode, name may contain slashes (e.g. 'a/b.txt').
384+
const slashIdx = StringPrototypeLastIndexOf(dirent.name, '/');
385+
if (slashIdx !== -1) {
386+
const subdir = StringPrototypeSlice(dirent.name, 0, slashIdx);
387+
dirent.parentPath = join(dirPath, subdir);
388+
dirent.name = StringPrototypeSlice(dirent.name, slashIdx + 1);
358389
}
359390
}
360391
}
361-
362392
return result;
363393
}
364394

@@ -380,6 +410,7 @@ class VirtualFileSystem {
380410
*/
381411
rmdirSync(dirPath) {
382412
const providerPath = this.#toProviderPath(dirPath);
413+
checkNotRoot(providerPath, 'rmdir', dirPath);
383414
this[kProvider].rmdirSync(providerPath);
384415
}
385416

@@ -400,6 +431,8 @@ class VirtualFileSystem {
400431
renameSync(oldPath, newPath) {
401432
const oldProviderPath = this.#toProviderPath(oldPath);
402433
const newProviderPath = this.#toProviderPath(newPath);
434+
checkNotRoot(oldProviderPath, 'rename', oldPath);
435+
checkNotRoot(newProviderPath, 'rename', newPath);
403436
this[kProvider].renameSync(oldProviderPath, newProviderPath);
404437
}
405438

@@ -768,7 +801,8 @@ class VirtualFileSystem {
768801
}
769802

770803
this[kProvider].readdir(this.#toProviderPath(dirPath), options)
771-
.then((entries) => callback(null, entries), (err) => callback(err));
804+
.then((entries) => callback(null, this.#toMountedDirents(dirPath, entries, options)),
805+
(err) => callback(err));
772806
}
773807

774808
/**
@@ -1128,6 +1162,8 @@ class VirtualFileSystem {
11281162
const toProviderPath = (p) => this.#toProviderPath(p);
11291163
const toProviderPrefix = (p) => this.#toProviderPrefix(p);
11301164
const toMountedPath = (p) => this.#toMountedPath(p);
1165+
const toMountedDirents = (p, result, options) =>
1166+
this.#toMountedDirents(p, result, options);
11311167

11321168
return ObjectFreeze({
11331169
async readFile(filePath, options) {
@@ -1157,7 +1193,8 @@ class VirtualFileSystem {
11571193

11581194
async readdir(dirPath, options) {
11591195
const providerPath = toProviderPath(dirPath);
1160-
return provider.readdir(providerPath, options);
1196+
const result = await provider.readdir(providerPath, options);
1197+
return toMountedDirents(dirPath, result, options);
11611198
},
11621199

11631200
async mkdir(dirPath, options) {
@@ -1168,6 +1205,7 @@ class VirtualFileSystem {
11681205

11691206
async rmdir(dirPath) {
11701207
const providerPath = toProviderPath(dirPath);
1208+
checkNotRoot(providerPath, 'rmdir', dirPath);
11711209
return provider.rmdir(providerPath);
11721210
},
11731211

@@ -1179,6 +1217,8 @@ class VirtualFileSystem {
11791217
async rename(oldPath, newPath) {
11801218
const oldProviderPath = toProviderPath(oldPath);
11811219
const newProviderPath = toProviderPath(newPath);
1220+
checkNotRoot(oldProviderPath, 'rename', oldPath);
1221+
checkNotRoot(newProviderPath, 'rename', newPath);
11821222
return provider.rename(oldProviderPath, newProviderPath);
11831223
},
11841224

@@ -1234,7 +1274,7 @@ class VirtualFileSystem {
12341274
for (let i = 0; i < entries.length; i++) {
12351275
await this.rm(joinPath(filePath, entries[i]), options);
12361276
}
1237-
await provider.rmdir(toProviderPath(filePath));
1277+
await this.rmdir(filePath);
12381278
} else {
12391279
await provider.unlink(toProviderPath(filePath));
12401280
}
@@ -1309,5 +1349,6 @@ class VirtualFileSystem {
13091349
module.exports = {
13101350
VirtualFileSystem,
13111351
kLayerId,
1352+
kReservedRoot,
13121353
normalizeMountedPath,
13131354
};

0 commit comments

Comments
 (0)