Skip to content

Commit badc074

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. 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 bca9bbe commit badc074

7 files changed

Lines changed: 600 additions & 36 deletions

File tree

‎doc/api/vfs.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ const mountPoint = myVfs.mount();
203203
fs.readFileSync(`${mountPoint}/data.txt`, 'utf8'); // 'Hello'
204204
```
205205

206+
Like any mount point, the mount point cannot be removed or renamed, nor
207+
replaced by renaming something else onto it: [`fs.rmdir()`][] and
208+
[`fs.rename()`][] fail with `EBUSY`. A recursive [`fs.rm()`][] of the mount
209+
point empties the file system before failing the same way.
210+
206211
Each `VirtualFileSystem` instance may be mounted at most once at a
207212
time. Attempting to mount an already-mounted instance throws
208213
`ERR_INVALID_STATE`. Because each instance mounts inside its own
@@ -380,6 +385,33 @@ The promise namespace mirrors `fs.promises` and includes `readFile`,
380385
`access`, `rm`, `truncate`, `link`, `mkdtemp`, `chmod`, `chown`, `lchown`,
381386
`utimes`, `lutimes`, `open`, `lchmod`, and `watch`.
382387

388+
## The reserved root directory
389+
390+
While any virtual file system is mounted, the directory that holds the mount
391+
points, `path.join(os.devNull, 'vfs')`, can be read through [`node:fs`][]. It
392+
contains a directory for every mounted file system, named like the last segment
393+
of its [`vfs.mountPoint`][].
394+
395+
```cjs
396+
const vfs = require('node:vfs');
397+
const fs = require('node:fs');
398+
const os = require('node:os');
399+
const path = require('node:path');
400+
401+
const root = path.join(os.devNull, 'vfs');
402+
const assets = vfs.create();
403+
assets.writeFileSync('/logo.svg', '<svg/>');
404+
const mountPoint = assets.mount();
405+
406+
fs.readdirSync(root); // e.g. [ '0' ]
407+
path.join(root, fs.readdirSync(root)[0]) === mountPoint; // true
408+
fs.readdirSync(root, { recursive: true }); // e.g. [ '0', '0/logo.svg' ]
409+
```
410+
411+
The root directory itself is read-only. Creating, removing, or changing its
412+
entries fails with `EROFS`, while the file systems its entries lead to can be
413+
written to as usual. When nothing is mounted, the root directory does not exist.
414+
383415
## Module loader integration
384416

385417
Once a `VirtualFileSystem` is mounted, paths under the mount point
@@ -711,6 +743,9 @@ fields use synthetic but stable values:
711743
[`ffi.dlopen()`]: ffi.md#ffidlopenpath-definitions
712744
[`fs.BigIntStats`]: fs.md#class-fsstats
713745
[`fs.Stats`]: fs.md#class-fsstats
746+
[`fs.rename()`]: fs.md#fsrenameoldpath-newpath-callback
747+
[`fs.rm()`]: fs.md#fsrmpath-options-callback
748+
[`fs.rmdir()`]: fs.md#fsrmdirpath-options-callback
714749
[`import.meta.resolve()`]: esm.md#importmetaresolvespecifier
715750
[`new ffi.DynamicLibrary()`]: ffi.md#new-dynamiclibrarypath
716751
[`node:fs`]: fs.md

‎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 & 22 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,6 +51,7 @@ const kNormalizedMountPoint = Symbol('kNormalizedMountPoint');
4751
const kMounted = Symbol('kMounted');
4852
const kPromises = Symbol('kPromises');
4953
const kLayerId = Symbol('kLayerId');
54+
const kReservedRoot = Symbol('kReservedRoot');
5055

5156
let nextLayerId = 0;
5257

@@ -74,6 +79,12 @@ function randomSuffix() {
7479
return suffix;
7580
}
7681

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

@@ -116,10 +127,20 @@ class VirtualFileSystem {
116127
}
117128

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

@@ -254,6 +275,8 @@ class VirtualFileSystem {
254275
*/
255276
#toMountedPath(providerPath) {
256277
if (this[kMounted] && this[kMountPoint]) {
278+
// path.join() would keep the trailing separator of the provider root.
279+
if (providerPath === '/') return this[kMountPoint];
257280
return path.join(this[kMountPoint], providerPath);
258281
}
259282
return providerPath;
@@ -337,28 +360,36 @@ class VirtualFileSystem {
337360
readdirSync(dirPath, options) {
338361
const providerPath = this.#toProviderPath(dirPath);
339362
const result = this[kProvider].readdirSync(providerPath, options);
363+
return this.#toMountedDirents(dirPath, result, options);
364+
}
340365

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;
366+
/**
367+
* Rewrites the Dirents of a listing of `dirPath` from provider-relative to
368+
* VFS paths, so that each `parentPath` is the directory the entry is in.
369+
* @param {string} dirPath The listed directory, as given by the caller
370+
* @param {string[]|Dirent[]} result The provider's listing
371+
* @param {object} [options] The readdir options
372+
* @returns {string[]|Dirent[]}
373+
*/
374+
#toMountedDirents(dirPath, result, options) {
375+
if (options?.withFileTypes !== true) return result;
376+
const recursive = options?.recursive === true;
377+
// A mounted VFS is addressed by host paths, so, like fs, join with the
378+
// host's separator; an unmounted one uses POSIX paths throughout.
379+
const join = this[kMounted] ? path.join : joinPath;
380+
for (let i = 0; i < result.length; i++) {
381+
const dirent = result[i];
382+
dirent.parentPath = dirPath;
383+
if (recursive) {
384+
// In recursive mode, name may contain slashes (e.g. 'a/b.txt').
385+
const slashIdx = StringPrototypeLastIndexOf(dirent.name, '/');
386+
if (slashIdx !== -1) {
387+
const subdir = StringPrototypeSlice(dirent.name, 0, slashIdx);
388+
dirent.parentPath = join(dirPath, subdir);
389+
dirent.name = StringPrototypeSlice(dirent.name, slashIdx + 1);
358390
}
359391
}
360392
}
361-
362393
return result;
363394
}
364395

@@ -380,6 +411,7 @@ class VirtualFileSystem {
380411
*/
381412
rmdirSync(dirPath) {
382413
const providerPath = this.#toProviderPath(dirPath);
414+
checkNotRoot(providerPath, 'rmdir', dirPath);
383415
this[kProvider].rmdirSync(providerPath);
384416
}
385417

@@ -400,6 +432,8 @@ class VirtualFileSystem {
400432
renameSync(oldPath, newPath) {
401433
const oldProviderPath = this.#toProviderPath(oldPath);
402434
const newProviderPath = this.#toProviderPath(newPath);
435+
checkNotRoot(oldProviderPath, 'rename', oldPath);
436+
checkNotRoot(newProviderPath, 'rename', newPath);
403437
this[kProvider].renameSync(oldProviderPath, newProviderPath);
404438
}
405439

@@ -768,7 +802,8 @@ class VirtualFileSystem {
768802
}
769803

770804
this[kProvider].readdir(this.#toProviderPath(dirPath), options)
771-
.then((entries) => callback(null, entries), (err) => callback(err));
805+
.then((entries) => callback(null, this.#toMountedDirents(dirPath, entries, options)),
806+
(err) => callback(err));
772807
}
773808

774809
/**
@@ -1128,6 +1163,8 @@ class VirtualFileSystem {
11281163
const toProviderPath = (p) => this.#toProviderPath(p);
11291164
const toProviderPrefix = (p) => this.#toProviderPrefix(p);
11301165
const toMountedPath = (p) => this.#toMountedPath(p);
1166+
const toMountedDirents = (p, result, options) =>
1167+
this.#toMountedDirents(p, result, options);
11311168

11321169
return ObjectFreeze({
11331170
async readFile(filePath, options) {
@@ -1157,7 +1194,8 @@ class VirtualFileSystem {
11571194

11581195
async readdir(dirPath, options) {
11591196
const providerPath = toProviderPath(dirPath);
1160-
return provider.readdir(providerPath, options);
1197+
const result = await provider.readdir(providerPath, options);
1198+
return toMountedDirents(dirPath, result, options);
11611199
},
11621200

11631201
async mkdir(dirPath, options) {
@@ -1168,6 +1206,7 @@ class VirtualFileSystem {
11681206

11691207
async rmdir(dirPath) {
11701208
const providerPath = toProviderPath(dirPath);
1209+
checkNotRoot(providerPath, 'rmdir', dirPath);
11711210
return provider.rmdir(providerPath);
11721211
},
11731212

@@ -1179,6 +1218,8 @@ class VirtualFileSystem {
11791218
async rename(oldPath, newPath) {
11801219
const oldProviderPath = toProviderPath(oldPath);
11811220
const newProviderPath = toProviderPath(newPath);
1221+
checkNotRoot(oldProviderPath, 'rename', oldPath);
1222+
checkNotRoot(newProviderPath, 'rename', newPath);
11821223
return provider.rename(oldProviderPath, newProviderPath);
11831224
},
11841225

@@ -1234,7 +1275,7 @@ class VirtualFileSystem {
12341275
for (let i = 0; i < entries.length; i++) {
12351276
await this.rm(joinPath(filePath, entries[i]), options);
12361277
}
1237-
await provider.rmdir(toProviderPath(filePath));
1278+
await this.rmdir(filePath);
12381279
} else {
12391280
await provider.unlink(toProviderPath(filePath));
12401281
}
@@ -1309,5 +1350,6 @@ class VirtualFileSystem {
13091350
module.exports = {
13101351
VirtualFileSystem,
13111352
kLayerId,
1353+
kReservedRoot,
13121354
normalizeMountedPath,
13131355
};

0 commit comments

Comments
 (0)