Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,29 @@ $ node --experimental-vfs --require ./provider.js \
--vfs-load archive.customfmt
```

## `vfs.vfsBase()`

<!-- YAML
added: REPLACEME
-->

* Returns: {string} The absolute path of the [reserved root directory][].

Returns the directory that holds the mount points of every mounted virtual file
system, which is `path.join(os.devNull, 'vfs')`. Reading it lists what is
mounted; see [The reserved root directory][reserved root directory].

```cjs
const vfs = require('node:vfs');
const fs = require('node:fs');

const myVfs = vfs.create();
const mountPoint = myVfs.mount();

fs.readdirSync(vfs.vfsBase()); // The name of every mount point in it
mountPoint.startsWith(vfs.vfsBase()); // true
```

## Class: `VirtualFileSystem`

<!-- YAML
Expand Down Expand Up @@ -187,9 +210,11 @@ After mounting, files in the VFS can be accessed through the
using paths under the returned mount point.

Mount points always live inside a reserved namespace that cannot have child file system entries,
so virtual paths never conflate with (or shadow) real paths. The virtual path scheme is subject to
change and users should not manually construct them based on assumptions. Instead, obtain
them from what `vfs.mount()` returns or `vfs.mountPoint`.
so virtual paths never conflate with (or shadow) real paths. A mount point is obtained from what
`vfs.mount()` returns or from [`vfs.mountPoint`][], and the mount points of all mounted file
systems can be listed by reading the [reserved root directory][], whose path [`vfs.vfsBase()`][]
returns. The name of a mount point within that directory is assigned at runtime, so it is not
something to construct or hard-code.

```cjs
const vfs = require('node:vfs');
Expand All @@ -203,6 +228,11 @@ const mountPoint = myVfs.mount();
fs.readFileSync(`${mountPoint}/data.txt`, 'utf8'); // 'Hello'
```

Like any mount point, the mount point cannot be removed or renamed, nor
replaced by renaming something else onto it: [`fs.rmdir()`][] and
[`fs.rename()`][] fail with `EBUSY`. A recursive [`fs.rm()`][] of the mount
point empties the file system before failing the same way.

Each `VirtualFileSystem` instance may be mounted at most once at a
time. Attempting to mount an already-mounted instance throws
`ERR_INVALID_STATE`. Because each instance mounts inside its own
Expand Down Expand Up @@ -380,6 +410,32 @@ The promise namespace mirrors `fs.promises` and includes `readFile`,
`access`, `rm`, `truncate`, `link`, `mkdtemp`, `chmod`, `chown`, `lchown`,
`utimes`, `lutimes`, `open`, `lchmod`, and `watch`.

## The reserved root directory

While any virtual file system is mounted, the directory that holds the mount
points can be read through [`node:fs`][]. [`vfs.vfsBase()`][] returns its path,
`path.join(os.devNull, 'vfs')`. It contains a directory for every mounted file
system, named like the last segment of its [`vfs.mountPoint`][].

```cjs
const vfs = require('node:vfs');
const fs = require('node:fs');
const path = require('node:path');

const root = vfs.vfsBase();
const assets = vfs.create();
assets.writeFileSync('/logo.svg', '<svg/>');
const mountPoint = assets.mount();

const name = path.basename(mountPoint);
fs.readdirSync(root); // [ name ]
fs.readdirSync(root, { recursive: true }); // [ name, `${name}/logo.svg` ]
```

The root directory itself is read-only. Creating, removing, or changing its
entries fails with `EROFS`, while the file systems its entries lead to can be
written to as usual. When nothing is mounted, the root directory does not exist.

## Module loader integration

Once a `VirtualFileSystem` is mounted, paths under the mount point
Expand Down Expand Up @@ -711,6 +767,9 @@ fields use synthetic but stable values:
[`ffi.dlopen()`]: ffi.md#ffidlopenpath-definitions
[`fs.BigIntStats`]: fs.md#class-fsstats
[`fs.Stats`]: fs.md#class-fsstats
[`fs.rename()`]: fs.md#fsrenameoldpath-newpath-callback
[`fs.rm()`]: fs.md#fsrmpath-options-callback
[`fs.rmdir()`]: fs.md#fsrmdirpath-options-callback
[`import.meta.resolve()`]: esm.md#importmetaresolvespecifier
[`new ffi.DynamicLibrary()`]: ffi.md#new-dynamiclibrarypath
[`node:fs`]: fs.md
Expand All @@ -721,8 +780,10 @@ fields use synthetic but stable values:
[`vfs.mountPointURL`]: #vfsmountpointurl
[`vfs.mountPoint`]: #vfsmountpoint
[`vfs.unmount()`]: #vfsunmount
[`vfs.vfsBase()`]: #vfsvfsbase
[`zipFile.writable`]: zlib.md#zipfilewritable
[`zlib.ZipBuffer`]: zlib.md#class-zlibzipbuffer
[`zlib.ZipFile`]: zlib.md#class-zlibzipfile
[loading from `node_modules` folders]: modules.md#loading-from-node_modules-folders
[reserved root directory]: #the-reserved-root-directory
[the global folders]: modules.md#loading-from-the-global-folders
12 changes: 12 additions & 0 deletions lib/internal/vfs/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
UV_EINVAL,
UV_ELOOP,
UV_EACCES,
UV_EBUSY,
UV_EXDEV,
} = internalBinding('uv');

Expand Down Expand Up @@ -180,6 +181,16 @@ function createEACCES(syscall, path) {
return err;
}

function createEBUSY(syscall, path) {
const err = new UVException({
errno: UV_EBUSY,
syscall,
path,
});
ErrorCaptureStackTrace(err, createEBUSY);
return err;
}

function createEXDEV(syscall, path) {
const err = new UVException({
errno: UV_EXDEV,
Expand All @@ -201,5 +212,6 @@ module.exports = {
createEINVAL,
createELOOP,
createEACCES,
createEBUSY,
createEXDEV,
};
87 changes: 64 additions & 23 deletions lib/internal/vfs/file_system.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
const {
MathRandom,
ObjectFreeze,
StringPrototypeLastIndexOf,
StringPrototypeSlice,
StringPrototypeStartsWith,
Symbol,
SymbolDispose,
Expand All @@ -21,6 +23,7 @@ const { join: joinPath } = pathPosix;
const {
getLayerRoot,
getRelativePath,
getVfsRoot,
} = require('internal/vfs/router');
const {
openVirtualFd,
Expand All @@ -30,6 +33,7 @@ const {
const {
createENOENT,
createEBADF,
createEBUSY,
createEISDIR,
} = require('internal/vfs/errors');
const { VirtualReadStream, VirtualWriteStream } = require('internal/vfs/streams');
Expand All @@ -47,7 +51,7 @@ const kNormalizedMountPoint = Symbol('kNormalizedMountPoint');
const kMounted = Symbol('kMounted');
const kPromises = Symbol('kPromises');
const kLayerId = Symbol('kLayerId');

const kReservedRoot = Symbol('kReservedRoot');
let nextLayerId = 0;

/**
Expand All @@ -74,6 +78,12 @@ function randomSuffix() {
return suffix;
}

// The root of a file system is its mount point, and like any mount point it
// cannot be removed or renamed, nor replaced by a rename.
function checkNotRoot(providerPath, syscall, path) {
if (providerPath === '/') throw createEBUSY(syscall, path);
}

let registerVFS;
let deregisterVFS;

Expand Down Expand Up @@ -116,10 +126,20 @@ class VirtualFileSystem {
}

this[kProvider] = provider ?? new MemoryProvider();
this[kPromises] = null;
if (options[kReservedRoot] === true) {
// Serves the reserved root directory itself. It is not a layer, so it
// takes no layer id and leaves the numbering of real mounts alone.
const root = getVfsRoot();
this[kMountPoint] = root;
this[kNormalizedMountPoint] = normalizeMountedPath(root);
this[kMounted] = true;
this[kLayerId] = -1;
return;
}
this[kMountPoint] = null;
this[kNormalizedMountPoint] = null;
this[kMounted] = false;
this[kPromises] = null;
this[kLayerId] = nextLayerId++;
}

Expand Down Expand Up @@ -254,6 +274,8 @@ class VirtualFileSystem {
*/
#toMountedPath(providerPath) {
if (this[kMounted] && this[kMountPoint]) {
// path.join() would keep the trailing separator of the provider root.
if (providerPath === '/') return this[kMountPoint];
return path.join(this[kMountPoint], providerPath);
}
return providerPath;
Expand Down Expand Up @@ -337,28 +359,36 @@ class VirtualFileSystem {
readdirSync(dirPath, options) {
const providerPath = this.#toProviderPath(dirPath);
const result = this[kProvider].readdirSync(providerPath, options);
return this.#toMountedDirents(dirPath, result, options);
}

// Rewrite Dirent parentPath from provider-relative to VFS path.
if (options?.withFileTypes === true) {
const recursive = options?.recursive === true;
for (let i = 0; i < result.length; i++) {
const dirent = result[i];
if (recursive) {
// In recursive mode, name may contain slashes (e.g. 'a/b.txt').
const slashIdx = dirent.name.lastIndexOf('/');
if (slashIdx !== -1) {
const subdir = dirent.name.slice(0, slashIdx);
dirent.parentPath = joinPath(dirPath, subdir);
dirent.name = dirent.name.slice(slashIdx + 1);
} else {
dirent.parentPath = dirPath;
}
} else {
dirent.parentPath = dirPath;
/**
* Rewrites the Dirents of a listing of `dirPath` from provider-relative to
* VFS paths, so that each `parentPath` is the directory the entry is in.
* @param {string} dirPath The listed directory, as given by the caller
* @param {string[]|Dirent[]} result The provider's listing
* @param {object} [options] The readdir options
* @returns {string[]|Dirent[]}
*/
#toMountedDirents(dirPath, result, options) {
if (options?.withFileTypes !== true) return result;
const recursive = options?.recursive === true;
// A mounted VFS is addressed by host paths, so, like fs, join with the
// host's separator; an unmounted one uses POSIX paths throughout.
const join = this[kMounted] ? path.join : joinPath;
for (let i = 0; i < result.length; i++) {
const dirent = result[i];
dirent.parentPath = dirPath;
if (recursive) {
// In recursive mode, name may contain slashes (e.g. 'a/b.txt').
const slashIdx = StringPrototypeLastIndexOf(dirent.name, '/');
if (slashIdx !== -1) {
const subdir = StringPrototypeSlice(dirent.name, 0, slashIdx);
dirent.parentPath = join(dirPath, subdir);
dirent.name = StringPrototypeSlice(dirent.name, slashIdx + 1);
}
}
}

return result;
}

Expand All @@ -380,6 +410,7 @@ class VirtualFileSystem {
*/
rmdirSync(dirPath) {
const providerPath = this.#toProviderPath(dirPath);
checkNotRoot(providerPath, 'rmdir', dirPath);
this[kProvider].rmdirSync(providerPath);
}

Expand All @@ -400,6 +431,8 @@ class VirtualFileSystem {
renameSync(oldPath, newPath) {
const oldProviderPath = this.#toProviderPath(oldPath);
const newProviderPath = this.#toProviderPath(newPath);
checkNotRoot(oldProviderPath, 'rename', oldPath);
checkNotRoot(newProviderPath, 'rename', newPath);
this[kProvider].renameSync(oldProviderPath, newProviderPath);
}

Expand Down Expand Up @@ -768,7 +801,8 @@ class VirtualFileSystem {
}

this[kProvider].readdir(this.#toProviderPath(dirPath), options)
.then((entries) => callback(null, entries), (err) => callback(err));
.then((entries) => callback(null, this.#toMountedDirents(dirPath, entries, options)),
(err) => callback(err));
}

/**
Expand Down Expand Up @@ -1128,6 +1162,8 @@ class VirtualFileSystem {
const toProviderPath = (p) => this.#toProviderPath(p);
const toProviderPrefix = (p) => this.#toProviderPrefix(p);
const toMountedPath = (p) => this.#toMountedPath(p);
const toMountedDirents = (p, result, options) =>
this.#toMountedDirents(p, result, options);

return ObjectFreeze({
async readFile(filePath, options) {
Expand Down Expand Up @@ -1157,7 +1193,8 @@ class VirtualFileSystem {

async readdir(dirPath, options) {
const providerPath = toProviderPath(dirPath);
return provider.readdir(providerPath, options);
const result = await provider.readdir(providerPath, options);
return toMountedDirents(dirPath, result, options);
},

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

async rmdir(dirPath) {
const providerPath = toProviderPath(dirPath);
checkNotRoot(providerPath, 'rmdir', dirPath);
return provider.rmdir(providerPath);
},

Expand All @@ -1179,6 +1217,8 @@ class VirtualFileSystem {
async rename(oldPath, newPath) {
const oldProviderPath = toProviderPath(oldPath);
const newProviderPath = toProviderPath(newPath);
checkNotRoot(oldProviderPath, 'rename', oldPath);
checkNotRoot(newProviderPath, 'rename', newPath);
return provider.rename(oldProviderPath, newProviderPath);
},

Expand Down Expand Up @@ -1234,7 +1274,7 @@ class VirtualFileSystem {
for (let i = 0; i < entries.length; i++) {
await this.rm(joinPath(filePath, entries[i]), options);
}
await provider.rmdir(toProviderPath(filePath));
await this.rmdir(filePath);
} else {
await provider.unlink(toProviderPath(filePath));
}
Expand Down Expand Up @@ -1309,5 +1349,6 @@ class VirtualFileSystem {
module.exports = {
VirtualFileSystem,
kLayerId,
kReservedRoot,
normalizeMountedPath,
};
Loading
Loading