Skip to content

Commit fe4c68b

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 342bf6d commit fe4c68b

8 files changed

Lines changed: 645 additions & 39 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 & 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
const kLoadLayer = Symbol('kLoadLayer');
5156

5257
// Layer 0 is reserved for the file system --vfs-load mounts, so that source is
@@ -80,6 +85,12 @@ function randomSuffix() {
8085
return suffix;
8186
}
8287

88+
// The root of a file system is its mount point, and like any mount point it
89+
// cannot be removed or renamed, nor replaced by a rename.
90+
function checkNotRoot(providerPath, syscall, path) {
91+
if (providerPath === '/') throw createEBUSY(syscall, path);
92+
}
93+
8394
let registerVFS;
8495
let deregisterVFS;
8596

@@ -122,10 +133,20 @@ class VirtualFileSystem {
122133
}
123134

124135
this[kProvider] = provider ?? new MemoryProvider();
136+
this[kPromises] = null;
137+
if (options[kReservedRoot] === true) {
138+
// Serves the reserved root directory itself. It is not a layer, so it
139+
// takes no layer id and leaves the numbering of real mounts alone.
140+
const root = getVfsRoot();
141+
this[kMountPoint] = root;
142+
this[kNormalizedMountPoint] = normalizeMountedPath(root);
143+
this[kMounted] = true;
144+
this[kLayerId] = -1;
145+
return;
146+
}
125147
this[kMountPoint] = null;
126148
this[kNormalizedMountPoint] = null;
127149
this[kMounted] = false;
128-
this[kPromises] = null;
129150
this[kLayerId] = options[kLoadLayer] === true ? kLoadLayerId : nextLayerId++;
130151
}
131152

@@ -260,6 +281,8 @@ class VirtualFileSystem {
260281
*/
261282
#toMountedPath(providerPath) {
262283
if (this[kMounted] && this[kMountPoint]) {
284+
// path.join() would keep the trailing separator of the provider root.
285+
if (providerPath === '/') return this[kMountPoint];
263286
return path.join(this[kMountPoint], providerPath);
264287
}
265288
return providerPath;
@@ -343,28 +366,36 @@ class VirtualFileSystem {
343366
readdirSync(dirPath, options) {
344367
const providerPath = this.#toProviderPath(dirPath);
345368
const result = this[kProvider].readdirSync(providerPath, options);
369+
return this.#toMountedDirents(dirPath, result, options);
370+
}
346371

347-
// Rewrite Dirent parentPath from provider-relative to VFS path.
348-
if (options?.withFileTypes === true) {
349-
const recursive = options?.recursive === true;
350-
for (let i = 0; i < result.length; i++) {
351-
const dirent = result[i];
352-
if (recursive) {
353-
// In recursive mode, name may contain slashes (e.g. 'a/b.txt').
354-
const slashIdx = dirent.name.lastIndexOf('/');
355-
if (slashIdx !== -1) {
356-
const subdir = dirent.name.slice(0, slashIdx);
357-
dirent.parentPath = joinPath(dirPath, subdir);
358-
dirent.name = dirent.name.slice(slashIdx + 1);
359-
} else {
360-
dirent.parentPath = dirPath;
361-
}
362-
} else {
363-
dirent.parentPath = dirPath;
372+
/**
373+
* Rewrites the Dirents of a listing of `dirPath` from provider-relative to
374+
* VFS paths, so that each `parentPath` is the directory the entry is in.
375+
* @param {string} dirPath The listed directory, as given by the caller
376+
* @param {string[]|Dirent[]} result The provider's listing
377+
* @param {object} [options] The readdir options
378+
* @returns {string[]|Dirent[]}
379+
*/
380+
#toMountedDirents(dirPath, result, options) {
381+
if (options?.withFileTypes !== true) return result;
382+
const recursive = options?.recursive === true;
383+
// A mounted VFS is addressed by host paths, so, like fs, join with the
384+
// host's separator; an unmounted one uses POSIX paths throughout.
385+
const join = this[kMounted] ? path.join : joinPath;
386+
for (let i = 0; i < result.length; i++) {
387+
const dirent = result[i];
388+
dirent.parentPath = dirPath;
389+
if (recursive) {
390+
// In recursive mode, name may contain slashes (e.g. 'a/b.txt').
391+
const slashIdx = StringPrototypeLastIndexOf(dirent.name, '/');
392+
if (slashIdx !== -1) {
393+
const subdir = StringPrototypeSlice(dirent.name, 0, slashIdx);
394+
dirent.parentPath = join(dirPath, subdir);
395+
dirent.name = StringPrototypeSlice(dirent.name, slashIdx + 1);
364396
}
365397
}
366398
}
367-
368399
return result;
369400
}
370401

@@ -386,6 +417,7 @@ class VirtualFileSystem {
386417
*/
387418
rmdirSync(dirPath) {
388419
const providerPath = this.#toProviderPath(dirPath);
420+
checkNotRoot(providerPath, 'rmdir', dirPath);
389421
this[kProvider].rmdirSync(providerPath);
390422
}
391423

@@ -406,6 +438,8 @@ class VirtualFileSystem {
406438
renameSync(oldPath, newPath) {
407439
const oldProviderPath = this.#toProviderPath(oldPath);
408440
const newProviderPath = this.#toProviderPath(newPath);
441+
checkNotRoot(oldProviderPath, 'rename', oldPath);
442+
checkNotRoot(newProviderPath, 'rename', newPath);
409443
this[kProvider].renameSync(oldProviderPath, newProviderPath);
410444
}
411445

@@ -774,7 +808,8 @@ class VirtualFileSystem {
774808
}
775809

776810
this[kProvider].readdir(this.#toProviderPath(dirPath), options)
777-
.then((entries) => callback(null, entries), (err) => callback(err));
811+
.then((entries) => callback(null, this.#toMountedDirents(dirPath, entries, options)),
812+
(err) => callback(err));
778813
}
779814

780815
/**
@@ -1134,6 +1169,8 @@ class VirtualFileSystem {
11341169
const toProviderPath = (p) => this.#toProviderPath(p);
11351170
const toProviderPrefix = (p) => this.#toProviderPrefix(p);
11361171
const toMountedPath = (p) => this.#toMountedPath(p);
1172+
const toMountedDirents = (p, result, options) =>
1173+
this.#toMountedDirents(p, result, options);
11371174

11381175
return ObjectFreeze({
11391176
async readFile(filePath, options) {
@@ -1163,7 +1200,8 @@ class VirtualFileSystem {
11631200

11641201
async readdir(dirPath, options) {
11651202
const providerPath = toProviderPath(dirPath);
1166-
return provider.readdir(providerPath, options);
1203+
const result = await provider.readdir(providerPath, options);
1204+
return toMountedDirents(dirPath, result, options);
11671205
},
11681206

11691207
async mkdir(dirPath, options) {
@@ -1174,6 +1212,7 @@ class VirtualFileSystem {
11741212

11751213
async rmdir(dirPath) {
11761214
const providerPath = toProviderPath(dirPath);
1215+
checkNotRoot(providerPath, 'rmdir', dirPath);
11771216
return provider.rmdir(providerPath);
11781217
},
11791218

@@ -1185,6 +1224,8 @@ class VirtualFileSystem {
11851224
async rename(oldPath, newPath) {
11861225
const oldProviderPath = toProviderPath(oldPath);
11871226
const newProviderPath = toProviderPath(newPath);
1227+
checkNotRoot(oldProviderPath, 'rename', oldPath);
1228+
checkNotRoot(newProviderPath, 'rename', newPath);
11881229
return provider.rename(oldProviderPath, newProviderPath);
11891230
},
11901231

@@ -1240,7 +1281,7 @@ class VirtualFileSystem {
12401281
for (let i = 0; i < entries.length; i++) {
12411282
await this.rm(joinPath(filePath, entries[i]), options);
12421283
}
1243-
await provider.rmdir(toProviderPath(filePath));
1284+
await this.rmdir(filePath);
12441285
} else {
12451286
await provider.unlink(toProviderPath(filePath));
12461287
}
@@ -1316,5 +1357,6 @@ module.exports = {
13161357
VirtualFileSystem,
13171358
kLayerId,
13181359
kLoadLayer,
1360+
kReservedRoot,
13191361
normalizeMountedPath,
13201362
};

0 commit comments

Comments
 (0)