Skip to content

Commit 9d82f1f

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 4782615 commit 9d82f1f

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

@@ -3832,7 +3833,8 @@ $ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
38323833
added: REPLACEME
38333834
-->
38343835

3835-
* `source` {string} A directory or an archive file to mount.
3836+
* `source` {string} A directory or an archive file to mount, optionally
3837+
preceded by `name=` to name the mount.
38363838

38373839
Requires [`--experimental-vfs`][]. May be repeated to mount several sources.
38383840

@@ -3850,6 +3852,23 @@ $ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
38503852
mounts `a`, `b` and `c` in that order and runs `b`. Mounts contributed by
38513853
[`NODE_OPTIONS`][] are mounted before the command line's.
38523854

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

@@ -4863,7 +4882,8 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
48634882
[`v8.startupSnapshot.addDeserializeCallback()`]: v8.md#v8startupsnapshotadddeserializecallbackcallback-data
48644883
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
48654884
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
4866-
[`vfs.mount()`]: vfs.md#vfsmount
4885+
[`vfs.mount()`]: vfs.md#vfsmountname
4886+
[`vfs.mount(name)`]: vfs.md#vfsmountname
48674887
[asynchronous module customization hooks]: module.md#asynchronous-customization-hooks
48684888
[benchmark runner]: bench.md#command-line-runner
48694889
[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. [ '1' ]
407-
path.join(root, fs.readdirSync(root)[0]) === mountPoint; // true
408-
fs.readdirSync(root, { recursive: true }); // e.g. [ '1', '1/logo.svg' ]
434+
fs.readdirSync(root); // e.g. [ '1', 'assets' ]
435+
fs.readdirSync(root, { recursive: true }); // e.g. [ '1', 'assets', '1/logo.svg' ]
436+
fs.readlinkSync(path.join(root, 'assets')); // e.g. '1'
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
@@ -1916,7 +1917,8 @@ $ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
19161917
.It Fl -vfs-mount Ns = Ns Ar source
19171918
.Bl -bullet
19181919
.It
1919-
\fBsource\fR \fB{string}\fR A directory or an archive file to mount.
1920+
\fBsource\fR \fB{string}\fR A directory or an archive file to mount, optionally
1921+
preceded by \fBname=\fR to name the mount.
19201922
.El
19211923
Requires \fB--experimental-vfs\fR. May be repeated to mount several sources.
19221924
Mounts \fBsource\fR as a virtual file system (\fBnode:vfs\fR). Each mount is placed
@@ -1929,6 +1931,19 @@ $ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
19291931
.Ed
19301932
mounts \fBa\fR, \fBb\fR and \fBc\fR in that order and runs \fBb\fR. Mounts contributed by
19311933
\fBNODE_OPTIONS\fR are mounted before the command line's.
1934+
A mount is named by writing its value as \fBname=source\fR, which mounts it as
1935+
\fBvfs.mount(name)\fR does: the program can then reach it as
1936+
\fBpath.join(os.devNull, 'vfs', name)\fR without knowing its mount point.
1937+
Everything before the first \fB=\fR in the value is the name, unless it contains a
1938+
path separator (\fB/\fR or \fB\\\fR), in which case the whole value is the source. A
1939+
source whose path contains \fB=\fR can therefore be mounted without a name by
1940+
writing it with a separator:
1941+
.Bd -literal
1942+
$ node --experimental-vfs --vfs-mount=assets=./build/assets.zip
1943+
$ node --experimental-vfs --vfs-mount=./a=b.zip
1944+
.Ed
1945+
The first mounts \fB./build/assets.zip\fR with the name \fBassets\fR; the second mounts
1946+
\fB./a=b.zip\fR without a name.
19321947
The provider backing a source is chosen from the source itself rather than from
19331948
its file name:
19341949
.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,
@@ -254,6 +255,22 @@ function getVfsLoadIndex(mountCount) {
254255
return mountCount - seen + found;
255256
}
256257

258+
// Splits a --vfs-mount/--vfs-load value of the form `[name=]source`. A prefix
259+
// holding a path separator is part of the source, so a path containing `=`
260+
// can still be mounted by writing it with a separator (`./a=b.zip`).
261+
function parseVfsMount(value) {
262+
const eq = StringPrototypeIndexOf(value, '=');
263+
if (eq === -1) return { name: undefined, source: value };
264+
const name = StringPrototypeSlice(value, 0, eq);
265+
if (StringPrototypeIncludes(name, '/') || StringPrototypeIncludes(name, '\\')) {
266+
return { name: undefined, source: value };
267+
}
268+
return {
269+
name: name === '' ? undefined : name,
270+
source: StringPrototypeSlice(value, eq + 1),
271+
};
272+
}
273+
257274
// Mounts every --vfs-mount source. Called from prepareExecution() when there is
258275
// no --import, and otherwise from run_main after the --import loop has run; the
259276
// guard makes the second call a no-op so a provider registered by either a -r or
@@ -277,7 +294,8 @@ function finishVfsMounts() {
277294
// own entry rather than the mount's.
278295
const loads = getOptionValue('[vfs_load_set]');
279296
for (let i = 0; i < entries.length; i++) {
280-
const resolvedSource = path.resolve(entries[i]);
297+
const { name, source } = parseVfsMount(entries[i]);
298+
const resolvedSource = path.resolve(source);
281299
let stats;
282300
try {
283301
stats = fs.statSync(resolvedSource);
@@ -296,7 +314,7 @@ function finishVfsMounts() {
296314
emitExperimentalWarning: false,
297315
[kLoadLayer]: i === loadIndex,
298316
});
299-
const mountPoint = vfs.mount();
317+
const mountPoint = vfs.mount(name);
300318
// The mount --vfs-load contributed is what the entry is require()d from;
301319
// process.argv[1] names the real source instead, since the reserved mount
302320
// 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,
@@ -91,6 +94,16 @@ function checkNotRoot(providerPath, syscall, path) {
9194
if (providerPath === '/') throw createEBUSY(syscall, path);
9295
}
9396

97+
function validateMountName(name) {
98+
validateString(name, 'name');
99+
if (name === '' || name === '.' || name === '..' || isLayerId(name) ||
100+
StringPrototypeIncludes(name, '/') || StringPrototypeIncludes(name, '\\') ||
101+
StringPrototypeIncludes(name, '\0')) {
102+
throw new ERR_INVALID_ARG_VALUE('name', name,
103+
'must be a single path segment not spelled as a number');
104+
}
105+
}
106+
94107
let registerVFS;
95108
let deregisterVFS;
96109

@@ -201,19 +214,27 @@ class VirtualFileSystem {
201214
* Windows; neither can have child filesystem entries) - so virtual
202215
* paths never conflate with real paths, and the owning layer of any
203216
* path is decidable from the path alone.
217+
*
218+
* A `name` additionally makes the mount reachable as
219+
* `${os.devNull}/vfs/<name>`, a symbolic link to the mount point. A later
220+
* mount with the same name takes the name over.
221+
* @param {string} [name] The name to mount under
204222
* @returns {string} The absolute mount point
205223
*/
206-
mount() {
224+
mount(name) {
225+
if (name !== undefined) {
226+
validateMountName(name);
227+
}
207228
if (this[kMounted]) {
208229
throw new ERR_INVALID_STATE('VFS is already mounted');
209230
}
210231
const mountPoint = getLayerRoot(this[kLayerId]);
211232
this[kMountPoint] = mountPoint;
212233
this[kNormalizedMountPoint] = normalizeMountedPath(mountPoint);
213234
this[kMounted] = true;
214-
debug('mount %s', mountPoint);
235+
debug('mount %s name=%s', mountPoint, name);
215236
loadVfsSetup();
216-
registerVFS(this);
237+
registerVFS(this, name);
217238
return mountPoint;
218239
}
219240

0 commit comments

Comments
 (0)