Skip to content

Commit 432118d

Browse files
committed
vfs: add mount names
Nothing identified a mounted virtual file system beyond its mount point, which is an opaque implementation detail. A mount made with --vfs-mount in particular could not be found by the program it was mounted for. Take an optional name as an argument to vfs.mount(). A named mount is also reachable as `${os.devNull}/vfs/<name>`, a symbolic link in the reserved root to the mount point: readdir() of the root lists it, readlink() returns the layer id, and realpath() resolves it to the mount point. A name at the start of a path is followed to its layer, except by the operations that act on a link itself (lstat, readlink, unlink, rm, rename, symlink and the l* variants), which see the link in the read-only root. require(), import and the fs functions therefore work through a name, and the loader identifies modules by their real mount point paths. Loader caches under a name are purged when the name moves to another mount or goes away. A later mount under a name takes it over, and unmounting removes the names linking to that mount. A name must be a single path segment other than `.` and `..`, and must not be spelled the way a layer id is, as a non-negative integer in canonical form: `17` is reserved, while `07` and `-1` are valid names. --vfs-mount and --vfs-load accept `name=source` and pass the name to vfs.mount(). Text before the first `=` is only a name if it has no path separator, so a path containing `=` can still be mounted by writing it as `./a=b`. --vfs-load takes the prefix too because a worker re-mounts every source without knowing which one --vfs-load contributed, and must split each value the same way. Add a benchmark for the cost of the fs hooks on a real path and on a layer path reached by id or by name. On Linux x64 (Release, statSync, n=100000, three runs), resolving through a name averaged about 2% below resolving by id, within the variation between runs. Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
1 parent badc074 commit 432118d

11 files changed

Lines changed: 728 additions & 230 deletions

File tree

‎benchmark/vfs/fs-resolve.js‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use strict';
2+
const fs = require('fs');
3+
const path = require('path');
4+
const common = require('../common.js');
5+
6+
// Measures what the fs hooks cost a call once a VFS is mounted: a real path
7+
// only has to be told apart from the VFS root, while a path in a layer is
8+
// resolved to the layer, either by its id or through its mount name.
9+
const bench = common.createBenchmark(main, {
10+
target: ['real', 'id', 'name'],
11+
n: [1e5],
12+
}, { flags: ['--experimental-vfs', '--no-warnings'] });
13+
14+
function main({ n, target }) {
15+
const vfs = require('node:vfs');
16+
const layer = vfs.create();
17+
layer.mkdirSync('/dir');
18+
layer.writeFileSync('/dir/file.txt', 'x');
19+
const mountPoint = layer.mount('bench');
20+
const file = {
21+
real: __filename,
22+
id: path.join(mountPoint, 'dir', 'file.txt'),
23+
name: path.join(path.dirname(mountPoint), 'bench', 'dir', 'file.txt'),
24+
}[target];
25+
26+
bench.start();
27+
for (let i = 0; i < n; i++) {
28+
fs.statSync(file);
29+
}
30+
bench.end(n);
31+
layer.unmount();
32+
}

‎doc/api/cli.md‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3791,7 +3791,8 @@ Print node's version.
37913791
added: REPLACEME
37923792
-->
37933793

3794-
* `source` {string} A directory or an archive file to mount and run.
3794+
* `source` {string} A directory or an archive file to mount and run, optionally
3795+
preceded by `name=` to name the mount as for [`--vfs-mount`][].
37953796

37963797
Requires [`--experimental-vfs`][]. May be given at most once.
37973798

@@ -3827,7 +3828,8 @@ $ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
38273828
added: REPLACEME
38283829
-->
38293830

3830-
* `source` {string} A directory or an archive file to mount.
3831+
* `source` {string} A directory or an archive file to mount, optionally
3832+
preceded by `name=` to name the mount.
38313833

38323834
Requires [`--experimental-vfs`][]. May be repeated to mount several sources.
38333835

@@ -3845,6 +3847,23 @@ $ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
38453847
mounts `a`, `b` and `c` in that order and runs `b`. Mounts contributed by
38463848
[`NODE_OPTIONS`][] are mounted before the command line's.
38473849

3850+
A mount is named by writing its value as `name=source`, which mounts it as
3851+
[`vfs.mount(name)`][] does: the program can then reach it as
3852+
`path.join(os.devNull, 'vfs', name)` without knowing its mount point.
3853+
3854+
Everything before the first `=` in the value is the name, unless it contains a
3855+
path separator (`/` or `\`), in which case the whole value is the source. A
3856+
source whose path contains `=` can therefore be mounted without a name by
3857+
writing it with a separator:
3858+
3859+
```console
3860+
$ node --experimental-vfs --vfs-mount=assets=./build/assets.zip
3861+
$ node --experimental-vfs --vfs-mount=./a=b.zip
3862+
```
3863+
3864+
The first mounts `./build/assets.zip` with the name `assets`; the second mounts
3865+
`./a=b.zip` without a name.
3866+
38483867
The provider backing a source is chosen from the source itself rather than from
38493868
its file name:
38503869

@@ -4858,7 +4877,8 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
48584877
[`v8.startupSnapshot.addDeserializeCallback()`]: v8.md#v8startupsnapshotadddeserializecallbackcallback-data
48594878
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
48604879
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
4861-
[`vfs.mount()`]: vfs.md#vfsmount
4880+
[`vfs.mount()`]: vfs.md#vfsmountname
4881+
[`vfs.mount(name)`]: vfs.md#vfsmountname
48624882
[asynchronous module customization hooks]: module.md#asynchronous-customization-hooks
48634883
[benchmark runner]: bench.md#command-line-runner
48644884
[captured by the built-in snapshot of Node.js]: https://github.com/nodejs/node/blob/b19525a33cc84033af4addd0f80acd4dc33ce0cf/test/parallel/test-bootstrap-modules.js#L24

‎doc/api/vfs.md‎

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,16 @@ added: v26.4.0
173173
* `emitExperimentalWarning` {boolean} Whether to emit the experimental
174174
warning. **Default:** `true`.
175175

176-
### `vfs.mount()`
176+
### `vfs.mount([name])`
177177

178178
<!-- YAML
179179
added: v26.9.0
180180
-->
181181

182+
* `name` {string} A name for the mount. It must be a single path segment other
183+
than `.` and `..`, and must not be spelled the way a layer id is, as a
184+
non-negative integer in its usual decimal form: `17` is reserved, while `07`
185+
and `-1` are valid names.
182186
* Returns: {string} The absolute mount point.
183187

184188
Mounts the virtual file system and returns the resulting mount point.
@@ -189,7 +193,7 @@ using paths under the returned mount point.
189193
Mount points always live inside a reserved namespace that cannot have child file system entries,
190194
so virtual paths never conflate with (or shadow) real paths. The virtual path scheme is subject to
191195
change and users should not manually construct them based on assumptions. Instead, obtain
192-
them from what `vfs.mount()` returns or `vfs.mountPoint`.
196+
them from what `vfs.mount()` returns or `vfs.mountPoint`, or mount under a `name`.
193197

194198
```cjs
195199
const vfs = require('node:vfs');
@@ -203,6 +207,29 @@ const mountPoint = myVfs.mount();
203207
fs.readFileSync(`${mountPoint}/data.txt`, 'utf8'); // 'Hello'
204208
```
205209

210+
A mount given a `name` can also be reached as
211+
`path.join(os.devNull, 'vfs', name)`, a symbolic link to the mount point in
212+
the [reserved root directory][]. This lets code that did not mount the file
213+
system find it without being handed the instance. A later mount with the same
214+
name takes the name over; the earlier file system stays mounted, but can then
215+
only be reached at its own mount point. The name is removed when the file system
216+
it links to is unmounted.
217+
218+
```cjs
219+
const vfs = require('node:vfs');
220+
const fs = require('node:fs');
221+
const os = require('node:os');
222+
const path = require('node:path');
223+
224+
const templates = vfs.create();
225+
templates.writeFileSync('/page.html', '<h1>Hello</h1>');
226+
templates.mount('templates');
227+
228+
// Elsewhere:
229+
const dir = path.join(os.devNull, 'vfs', 'templates');
230+
fs.readFileSync(path.join(dir, 'page.html'), 'utf8'); // '<h1>Hello</h1>'
231+
```
232+
206233
Like any mount point, the mount point cannot be removed or renamed, nor
207234
replaced by renaming something else onto it: [`fs.rmdir()`][] and
208235
[`fs.rename()`][] fail with `EBUSY`. A recursive [`fs.rm()`][] of the mount
@@ -390,7 +417,8 @@ The promise namespace mirrors `fs.promises` and includes `readFile`,
390417
While any virtual file system is mounted, the directory that holds the mount
391418
points, `path.join(os.devNull, 'vfs')`, can be read through [`node:fs`][]. It
392419
contains a directory for every mounted file system, named like the last segment
393-
of its [`vfs.mountPoint`][].
420+
of its [`vfs.mountPoint`][], and a symbolic link for every name given to
421+
[`vfs.mount()`][], pointing at the mount it names.
394422

395423
```cjs
396424
const vfs = require('node:vfs');
@@ -401,13 +429,21 @@ const path = require('node:path');
401429
const root = path.join(os.devNull, 'vfs');
402430
const assets = vfs.create();
403431
assets.writeFileSync('/logo.svg', '<svg/>');
404-
const mountPoint = assets.mount();
432+
const mountPoint = assets.mount('assets');
405433

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' ]
434+
fs.readdirSync(root); // e.g. [ '0', 'assets' ]
435+
fs.readdirSync(root, { recursive: true }); // e.g. [ '0', 'assets', '0/logo.svg' ]
436+
fs.readlinkSync(path.join(root, 'assets')); // e.g. '0'
437+
fs.realpathSync(path.join(root, 'assets')) === mountPoint; // true
438+
fs.readFileSync(path.join(root, 'assets', 'logo.svg'), 'utf8'); // '<svg/>'
409439
```
410440

441+
A path through a name works wherever the same path through the mount point
442+
does, including in `require()` and `import`. As with any symbolic link,
443+
[`fs.realpath()`][] resolves it to the path under the mount point, and so does
444+
the module loader: a module loaded through a name is identified by its path
445+
under the mount point.
446+
411447
The root directory itself is read-only. Creating, removing, or changing its
412448
entries fails with `EROFS`, while the file systems its entries lead to can be
413449
written to as usual. When nothing is mounted, the root directory does not exist.
@@ -743,6 +779,7 @@ fields use synthetic but stable values:
743779
[`ffi.dlopen()`]: ffi.md#ffidlopenpath-definitions
744780
[`fs.BigIntStats`]: fs.md#class-fsstats
745781
[`fs.Stats`]: fs.md#class-fsstats
782+
[`fs.realpath()`]: fs.md#fsrealpathpath-options-callback
746783
[`fs.rename()`]: fs.md#fsrenameoldpath-newpath-callback
747784
[`fs.rm()`]: fs.md#fsrmpath-options-callback
748785
[`fs.rmdir()`]: fs.md#fsrmdirpath-options-callback
@@ -752,12 +789,13 @@ fields use synthetic but stable values:
752789
[`require()`]: modules.md#requireid
753790
[`require.resolve()`]: modules.md#requireresolverequest-options
754791
[`url.pathToFileURL()`]: url.md#urlpathtofileurlpath-options
755-
[`vfs.mount()`]: #vfsmount
792+
[`vfs.mount()`]: #vfsmountname
756793
[`vfs.mountPointURL`]: #vfsmountpointurl
757794
[`vfs.mountPoint`]: #vfsmountpoint
758795
[`vfs.unmount()`]: #vfsunmount
759796
[`zipFile.writable`]: zlib.md#zipfilewritable
760797
[`zlib.ZipBuffer`]: zlib.md#class-zlibzipbuffer
761798
[`zlib.ZipFile`]: zlib.md#class-zlibzipfile
762799
[loading from `node_modules` folders]: modules.md#loading-from-node_modules-folders
800+
[reserved root directory]: #the-reserved-root-directory
763801
[the global folders]: modules.md#loading-from-the-global-folders

‎doc/node.1‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,7 +1885,8 @@ Print node's version.
18851885
.It Fl -vfs-load Ns = Ns Ar source
18861886
.Bl -bullet
18871887
.It
1888-
\fBsource\fR \fB{string}\fR A directory or an archive file to mount and run.
1888+
\fBsource\fR \fB{string}\fR A directory or an archive file to mount and run, optionally
1889+
preceded by \fBname=\fR to name the mount as for \fB--vfs-mount\fR.
18891890
.El
18901891
Requires \fB--experimental-vfs\fR. May be given at most once.
18911892
Mounts \fBsource\fR exactly as \fB--vfs-mount\fR does, and additionally runs the
@@ -1912,7 +1913,8 @@ $ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
19121913
.It Fl -vfs-mount Ns = Ns Ar source
19131914
.Bl -bullet
19141915
.It
1915-
\fBsource\fR \fB{string}\fR A directory or an archive file to mount.
1916+
\fBsource\fR \fB{string}\fR A directory or an archive file to mount, optionally
1917+
preceded by \fBname=\fR to name the mount.
19161918
.El
19171919
Requires \fB--experimental-vfs\fR. May be repeated to mount several sources.
19181920
Mounts \fBsource\fR as a virtual file system (\fBnode:vfs\fR). Each mount is placed
@@ -1925,6 +1927,19 @@ $ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
19251927
.Ed
19261928
mounts \fBa\fR, \fBb\fR and \fBc\fR in that order and runs \fBb\fR. Mounts contributed by
19271929
\fBNODE_OPTIONS\fR are mounted before the command line's.
1930+
A mount is named by writing its value as \fBname=source\fR, which mounts it as
1931+
\fBvfs.mount(name)\fR does: the program can then reach it as
1932+
\fBpath.join(os.devNull, 'vfs', name)\fR without knowing its mount point.
1933+
Everything before the first \fB=\fR in the value is the name, unless it contains a
1934+
path separator (\fB/\fR or \fB\\\fR), in which case the whole value is the source. A
1935+
source whose path contains \fB=\fR can therefore be mounted without a name by
1936+
writing it with a separator:
1937+
.Bd -literal
1938+
$ node --experimental-vfs --vfs-mount=assets=./build/assets.zip
1939+
$ node --experimental-vfs --vfs-mount=./a=b.zip
1940+
.Ed
1941+
The first mounts \fB./build/assets.zip\fR with the name \fBassets\fR; the second mounts
1942+
\fB./a=b.zip\fR without a name.
19281943
The provider backing a source is chosen from the source itself rather than from
19291944
its file name:
19301945
.Bl -bullet

‎lib/internal/process/pre_execution.js‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
ObjectDefineProperty,
1515
ObjectFreeze,
1616
String,
17+
StringPrototypeIncludes,
1718
StringPrototypeIndexOf,
1819
StringPrototypeSlice,
1920
globalThis,
@@ -252,6 +253,22 @@ function getVfsLoadIndex(mountCount) {
252253
return mountCount - seen + found;
253254
}
254255

256+
// Splits a --vfs-mount/--vfs-load value of the form `[name=]source`. A prefix
257+
// holding a path separator is part of the source, so a path containing `=`
258+
// can still be mounted by writing it with a separator (`./a=b.zip`).
259+
function parseVfsMount(value) {
260+
const eq = StringPrototypeIndexOf(value, '=');
261+
if (eq === -1) return { name: undefined, source: value };
262+
const name = StringPrototypeSlice(value, 0, eq);
263+
if (StringPrototypeIncludes(name, '/') || StringPrototypeIncludes(name, '\\')) {
264+
return { name: undefined, source: value };
265+
}
266+
return {
267+
name: name === '' ? undefined : name,
268+
source: StringPrototypeSlice(value, eq + 1),
269+
};
270+
}
271+
255272
// Mounts every --vfs-mount source. Called from prepareExecution() when there is
256273
// no --import, and otherwise from run_main after the --import loop has run; the
257274
// guard makes the second call a no-op so a provider registered by either a -r or
@@ -274,7 +291,8 @@ function finishVfsMounts() {
274291
// the same order (the reserved paths line up) but runs its own entry.
275292
const loadIndex = getVfsLoadIndex(entries.length);
276293
for (let i = 0; i < entries.length; i++) {
277-
const resolvedSource = path.resolve(entries[i]);
294+
const { name, source } = parseVfsMount(entries[i]);
295+
const resolvedSource = path.resolve(source);
278296
let stats;
279297
try {
280298
stats = fs.statSync(resolvedSource);
@@ -289,7 +307,7 @@ function finishVfsMounts() {
289307
throw new ERR_VFS_INVALID_TARGET(resolvedSource);
290308
}
291309
const vfs = new VirtualFileSystem(provider, { emitExperimentalWarning: false });
292-
const mountPoint = vfs.mount();
310+
const mountPoint = vfs.mount(name);
293311
// The mount --vfs-load contributed is what the entry is require()d from;
294312
// process.argv[1] names the real source instead, since the reserved mount
295313
// point is an opaque implementation detail.

‎lib/internal/vfs/file_system.js‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const {
44
MathRandom,
55
ObjectFreeze,
6+
StringPrototypeIncludes,
67
StringPrototypeLastIndexOf,
78
StringPrototypeSlice,
89
StringPrototypeStartsWith,
@@ -12,10 +13,11 @@ const {
1213

1314
const {
1415
codes: {
16+
ERR_INVALID_ARG_VALUE,
1517
ERR_INVALID_STATE,
1618
},
1719
} = require('internal/errors');
18-
const { validateBoolean } = require('internal/validators');
20+
const { validateBoolean, validateString } = require('internal/validators');
1921
const { MemoryProvider } = require('internal/vfs/providers/memory');
2022
const path = require('path');
2123
const { posix: pathPosix, resolve: resolvePath, sep, toNamespacedPath } = path;
@@ -24,6 +26,7 @@ const {
2426
getLayerRoot,
2527
getRelativePath,
2628
getVfsRoot,
29+
isLayerId,
2730
} = require('internal/vfs/router');
2831
const {
2932
openVirtualFd,
@@ -85,6 +88,16 @@ function checkNotRoot(providerPath, syscall, path) {
8588
if (providerPath === '/') throw createEBUSY(syscall, path);
8689
}
8790

91+
function validateMountName(name) {
92+
validateString(name, 'name');
93+
if (name === '' || name === '.' || name === '..' || isLayerId(name) ||
94+
StringPrototypeIncludes(name, '/') || StringPrototypeIncludes(name, '\\') ||
95+
StringPrototypeIncludes(name, '\0')) {
96+
throw new ERR_INVALID_ARG_VALUE('name', name,
97+
'must be a single path segment not spelled as a number');
98+
}
99+
}
100+
88101
let registerVFS;
89102
let deregisterVFS;
90103

@@ -195,19 +208,27 @@ class VirtualFileSystem {
195208
* Windows; neither can have child filesystem entries) - so virtual
196209
* paths never conflate with real paths, and the owning layer of any
197210
* path is decidable from the path alone.
211+
*
212+
* A `name` additionally makes the mount reachable as
213+
* `${os.devNull}/vfs/<name>`, a symbolic link to the mount point. A later
214+
* mount with the same name takes the name over.
215+
* @param {string} [name] The name to mount under
198216
* @returns {string} The absolute mount point
199217
*/
200-
mount() {
218+
mount(name) {
219+
if (name !== undefined) {
220+
validateMountName(name);
221+
}
201222
if (this[kMounted]) {
202223
throw new ERR_INVALID_STATE('VFS is already mounted');
203224
}
204225
const mountPoint = getLayerRoot(this[kLayerId]);
205226
this[kMountPoint] = mountPoint;
206227
this[kNormalizedMountPoint] = normalizeMountedPath(mountPoint);
207228
this[kMounted] = true;
208-
debug('mount %s', mountPoint);
229+
debug('mount %s name=%s', mountPoint, name);
209230
loadVfsSetup();
210-
registerVFS(this);
231+
registerVFS(this, name);
211232
return mountPoint;
212233
}
213234

0 commit comments

Comments
 (0)