node/lib/internal/vfs/setup.js
Matteo Collina 569369f927
vfs: dispatch fs/promises to mounted VFS instances
Add mount/unmount lifecycle on `VirtualFileSystem`, a handler registry
that fs.js and fs/promises.js consult via `vfsState.handlers`, and a
router that maps absolute paths to the VFS that owns them. When a VFS
is mounted, the public `fs.*` and `fs/promises` APIs (including
streams, `fs.watch`, and `opendir`) dispatch to the provider for paths
under the mount point, and fall through to the real filesystem
otherwise. Includes per-method dispatch tests, error-path coverage,
multi-mount routing tests, and router unit tests.

Ref: https://github.com/nodejs/node/pull/63115

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/63537
Refs: https://github.com/nodejs/node/pull/63115
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
2026-05-29 17:57:34 +00:00

661 lines
23 KiB
JavaScript

'use strict';
const {
ArrayPrototypeIndexOf,
ArrayPrototypePush,
ArrayPrototypeSplice,
PromiseResolve,
StringPrototypeStartsWith,
} = primordials;
const { Buffer } = require('buffer');
const { resolve, sep } = require('path');
const { fileURLToPath, URL } = require('internal/url');
const { kEmptyObject } = require('internal/util');
const { validateObject } = require('internal/validators');
const {
codes: {
ERR_INVALID_ARG_TYPE,
ERR_INVALID_STATE,
},
} = require('internal/errors');
const { createENOENT, createEXDEV } = require('internal/vfs/errors');
const { getVirtualFd, closeVirtualFd } = require('internal/vfs/fd');
const { assertEncoding, vfsState, setVfsHandlers } = require('internal/fs/utils');
const permission = require('internal/process/permission');
const { getOptionValue } = require('internal/options');
let debug = require('internal/util/debuglog').debuglog('vfs', (fn) => {
debug = fn;
});
function toPathStr(pathOrUrl) {
if (typeof pathOrUrl === 'string') return pathOrUrl;
if (pathOrUrl instanceof URL) return fileURLToPath(pathOrUrl);
if (Buffer.isBuffer(pathOrUrl)) return pathOrUrl.toString();
return null;
}
function noopFdSync(fd) {
if (getVirtualFd(fd)) return true;
return undefined;
}
const noopFdPromise = PromiseResolve(true);
function noopFd(fd) {
if (getVirtualFd(fd)) return noopFdPromise;
return undefined;
}
// Registry of active VFS instances.
const activeVFSList = [];
let hooksInstalled = false;
let vfsHandlerObj;
function registerVFS(vfs) {
if (permission.isEnabled() && !getOptionValue('--allow-fs-vfs')) {
throw new ERR_INVALID_STATE(
'VFS cannot be used when the permission model is enabled. ' +
'Use --allow-fs-vfs to allow it.',
);
}
if (ArrayPrototypeIndexOf(activeVFSList, vfs) !== -1) return;
const newMount = vfs.mountPoint;
if (newMount != null) {
for (let i = 0; i < activeVFSList.length; i++) {
const existingMount = activeVFSList[i].mountPoint;
if (existingMount == null) continue;
// Use path.sep so the trailing-separator guard works on Windows where
// mountPoint values are resolved to drive-letter / backslash paths.
const newPrefix = newMount === sep ? sep : newMount + sep;
const existingPrefix = existingMount === sep ? sep : existingMount + sep;
if (newMount === existingMount ||
StringPrototypeStartsWith(newMount, existingPrefix) ||
StringPrototypeStartsWith(existingMount, newPrefix)) {
throw new ERR_INVALID_STATE(
`VFS mount '${newMount}' overlaps with existing mount '${existingMount}'`,
);
}
}
}
ArrayPrototypePush(activeVFSList, vfs);
debug('register mount=%s active=%d', newMount, activeVFSList.length);
if (!hooksInstalled) {
vfsHandlerObj = createVfsHandlers();
setVfsHandlers(vfsHandlerObj);
hooksInstalled = true;
} else if (vfsState.handlers === null) {
setVfsHandlers(vfsHandlerObj);
}
}
function deregisterVFS(vfs) {
const index = ArrayPrototypeIndexOf(activeVFSList, vfs);
if (index === -1) return;
ArrayPrototypeSplice(activeVFSList, index, 1);
debug('deregister active=%d', activeVFSList.length);
if (activeVFSList.length === 0) {
setVfsHandlers(null);
}
}
function findVFSForExists(filename) {
const normalized = resolve(filename);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
if (vfs.shouldHandle(normalized)) {
return { vfs, exists: vfs.existsSync(normalized) };
}
}
return null;
}
function findVFSForPath(filename) {
const normalized = resolve(filename);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
if (vfs.shouldHandle(normalized)) {
return { vfs, normalized };
}
}
return null;
}
// Sync read: check exists first, fall through to ENOENT for mounted VFS.
function findVFSWith(filename, syscall, fn) {
const normalized = resolve(filename);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
if (vfs.shouldHandle(normalized)) {
if (vfs.existsSync(normalized)) {
return fn(vfs, normalized);
}
throw createENOENT(syscall, filename);
}
}
return undefined;
}
function vfsRead(path, syscall, fn) {
const pathStr = toPathStr(path);
if (pathStr === null) return undefined;
return findVFSWith(pathStr, syscall, fn);
}
function vfsOp(path, fn) {
const pathStr = toPathStr(path);
if (pathStr !== null) {
const r = findVFSForPath(pathStr);
if (r !== null) return fn(r.vfs, r.normalized);
}
return undefined;
}
function vfsOpVoid(path, fn) {
const pathStr = toPathStr(path);
if (pathStr !== null) {
const r = findVFSForPath(pathStr);
if (r !== null) { fn(r.vfs, r.normalized); return true; }
}
return undefined;
}
function checkSameVFS(srcPath, destPath, syscall, srcVfs) {
const destNormalized = resolve(destPath);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
if (vfs.shouldHandle(destNormalized)) {
if (vfs !== srcVfs) {
throw createEXDEV(syscall, srcPath);
}
return;
}
}
throw createEXDEV(syscall, srcPath);
}
function createVfsHandlers() {
return {
__proto__: null,
// ==================== Sync path-based read ops ====================
existsSync(path) {
const pathStr = toPathStr(path);
if (pathStr === null) return undefined;
const r = findVFSForExists(pathStr);
return r !== null ? r.exists : undefined;
},
readFileSync(path, options) {
if (typeof path === 'number') {
const vfd = getVirtualFd(path);
if (vfd) {
const enc = typeof options === 'string' ? options : options?.encoding;
if (enc && enc !== 'buffer') assertEncoding(enc);
return vfd.entry.readFileSync(options);
}
return undefined;
}
const pathStr = toPathStr(path);
if (pathStr === null) return undefined;
const enc = typeof options === 'string' ? options : options?.encoding;
if (enc && enc !== 'buffer') assertEncoding(enc);
return findVFSWith(pathStr, 'open', (vfs, n) => vfs.readFileSync(n, options));
},
readdirSync(path, options) {
const result = vfsRead(path, 'scandir', (vfs, n) => vfs.readdirSync(n, options));
if (result !== undefined && options?.encoding === 'buffer' && !options?.withFileTypes) {
for (let i = 0; i < result.length; i++) {
if (typeof result[i] === 'string') result[i] = Buffer.from(result[i]);
}
}
return result;
},
lstatSync(path, options) {
const pathStr = toPathStr(path);
if (pathStr === null) return undefined;
const normalized = resolve(pathStr);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
if (vfs.shouldHandle(normalized)) {
try {
return vfs.lstatSync(normalized, options);
} catch (e) {
if (e?.code === 'ENOENT' && options?.throwIfNoEntry === false) return undefined;
throw e;
}
}
}
return undefined;
},
statSync(path, options) {
try {
return vfsRead(path, 'stat', (vfs, n) => vfs.statSync(n, options));
} catch (err) {
if (err?.code === 'ENOENT' && options?.throwIfNoEntry === false) return undefined;
throw err;
}
},
realpathSync(path, options) {
const result = vfsRead(path, 'realpath', (vfs, n) => vfs.realpathSync(n));
if (result !== undefined && options?.encoding === 'buffer') {
return Buffer.from(result);
}
return result;
},
accessSync(path, mode) {
const pathStr = toPathStr(path);
if (pathStr !== null) {
const r = findVFSForPath(pathStr);
if (r !== null) {
if (mode != null && typeof mode !== 'number') {
throw new ERR_INVALID_ARG_TYPE('mode', 'integer', mode);
}
r.vfs.accessSync(r.normalized, mode);
return true;
}
}
return undefined;
},
readlinkSync(path, options) {
const pathStr = toPathStr(path);
if (pathStr === null) return undefined;
const normalized = resolve(pathStr);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
if (vfs.shouldHandle(normalized)) {
const result = vfs.readlinkSync(normalized, options);
if (options?.encoding === 'buffer') return Buffer.from(result);
return result;
}
}
return undefined;
},
statfsSync(path, options) {
const pathStr = toPathStr(path);
if (pathStr !== null && findVFSForPath(pathStr) !== null) {
if (options?.bigint) {
return {
type: 0n, bsize: 4096n, blocks: 0n,
bfree: 0n, bavail: 0n, files: 0n, ffree: 0n,
};
}
return { type: 0, bsize: 4096, blocks: 0, bfree: 0, bavail: 0, files: 0, ffree: 0 };
}
return undefined;
},
// ==================== Sync path-based write ops ====================
writeFileSync: (path, data, options) =>
vfsOpVoid(path, (vfs, n) => vfs.writeFileSync(n, data, options)),
appendFileSync: (path, data, options) =>
vfsOpVoid(path, (vfs, n) => vfs.appendFileSync(n, data, options)),
mkdirSync: (path, options) =>
vfsOp(path, (vfs, n) => ({ result: vfs.mkdirSync(n, options) })),
rmdirSync: (path) => vfsOpVoid(path, (vfs, n) => vfs.rmdirSync(n)),
rmSync: (path, options) => vfsOpVoid(path, (vfs, n) => vfs.rmSync(n, options)),
unlinkSync: (path) => vfsOpVoid(path, (vfs, n) => vfs.unlinkSync(n)),
renameSync(oldPath, newPath) {
return vfsOpVoid(oldPath, (vfs, n) => {
checkSameVFS(n, toPathStr(newPath), 'rename', vfs);
vfs.renameSync(n, resolve(toPathStr(newPath)));
});
},
copyFileSync(src, dest, mode) {
return vfsOpVoid(src, (vfs, n) => {
checkSameVFS(n, toPathStr(dest), 'copyfile', vfs);
vfs.copyFileSync(n, resolve(toPathStr(dest)), mode);
});
},
symlinkSync: (target, path, type) =>
vfsOpVoid(path, (vfs, n) => vfs.symlinkSync(target, n, type)),
chmodSync: (path, mode) => vfsOpVoid(path, (vfs, n) => vfs.chmodSync(n, mode)),
chownSync: (path, uid, gid) => vfsOpVoid(path, (vfs, n) => vfs.chownSync(n, uid, gid)),
lchownSync: (path, uid, gid) => vfsOpVoid(path, (vfs, n) => vfs.chownSync(n, uid, gid)),
utimesSync: (path, atime, mtime) =>
vfsOpVoid(path, (vfs, n) => vfs.utimesSync(n, atime, mtime)),
lutimesSync: (path, atime, mtime) =>
vfsOpVoid(path, (vfs, n) => vfs.lutimesSync(n, atime, mtime)),
truncateSync: (path, len) => vfsOpVoid(path, (vfs, n) => vfs.truncateSync(n, len)),
linkSync(existingPath, newPath) {
return vfsOpVoid(existingPath, (vfs, n) => {
checkSameVFS(n, toPathStr(newPath), 'link', vfs);
vfs.linkSync(n, resolve(toPathStr(newPath)));
});
},
mkdtempSync(prefix, options) {
const result = vfsOp(prefix, (vfs, n) => vfs.mkdtempSync(n));
if (result !== undefined && options?.encoding === 'buffer') {
return Buffer.from(result);
}
return result;
},
opendirSync: (path, options) => vfsOp(path, (vfs, n) => vfs.opendirSync(n, options)),
openAsBlob(path, options) {
const pathStr = toPathStr(path);
if (pathStr !== null) {
const normalized = resolve(pathStr);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
if (vfs.shouldHandle(normalized) && vfs.existsSync(normalized)) {
return vfs.openAsBlob(normalized, options);
}
}
}
return undefined;
},
// ==================== Sync FD-based ops ====================
openSync: (path, flags, mode) => vfsOp(path, (vfs, n) => vfs.openSync(n, flags, mode)),
closeSync(fd) {
const vfd = getVirtualFd(fd);
if (vfd) { vfd.entry.closeSync(); closeVirtualFd(fd); return true; }
return undefined;
},
readSync(fd, buffer, offset, length, position) {
const vfd = getVirtualFd(fd);
if (vfd) return vfd.entry.readSync(buffer, offset, length, position);
return undefined;
},
writeSync(fd, buffer, offset, length, position) {
const vfd = getVirtualFd(fd);
if (vfd) return vfd.entry.writeSync(buffer, offset, length, position);
return undefined;
},
fstatSync(fd, options) {
const vfd = getVirtualFd(fd);
if (vfd) return vfd.entry.statSync(options);
return undefined;
},
ftruncateSync(fd, len) {
const vfd = getVirtualFd(fd);
if (vfd) { vfd.entry.truncateSync(len); return true; }
return undefined;
},
fchmodSync: noopFdSync,
fchownSync: noopFdSync,
futimesSync: noopFdSync,
fdatasyncSync: noopFdSync,
fsyncSync: noopFdSync,
readvSync(fd, buffers, position) {
const vfd = getVirtualFd(fd);
if (!vfd) return undefined;
let totalRead = 0;
for (let i = 0; i < buffers.length; i++) {
const buf = buffers[i];
const pos = position != null ? position + totalRead : position;
const bytesRead = vfd.entry.readSync(buf, 0, buf.byteLength, pos);
totalRead += bytesRead;
if (bytesRead < buf.byteLength) break;
}
return totalRead;
},
writevSync(fd, buffers, position) {
const vfd = getVirtualFd(fd);
if (!vfd) return undefined;
let totalWritten = 0;
for (let i = 0; i < buffers.length; i++) {
const buf = buffers[i];
const pos = position != null ? position + totalWritten : position;
const bytesWritten = vfd.entry.writeSync(buf, 0, buf.byteLength, pos);
totalWritten += bytesWritten;
if (bytesWritten < buf.byteLength) break;
}
return totalWritten;
},
// ==================== Async FD-based ops ====================
close(fd) {
const vfd = getVirtualFd(fd);
if (!vfd) return undefined;
return vfd.entry.close().then(() => { closeVirtualFd(fd); return true; });
},
read(fd, buffer, offset, length, position) {
const vfd = getVirtualFd(fd);
if (!vfd) return undefined;
return vfd.entry.read(buffer, offset, length, position)
.then(({ bytesRead }) => bytesRead);
},
write(fd, buffer, offset, length, position) {
const vfd = getVirtualFd(fd);
if (!vfd) return undefined;
return vfd.entry.write(buffer, offset, length, position)
.then(({ bytesWritten }) => bytesWritten);
},
fstat(fd, options) {
const vfd = getVirtualFd(fd);
if (!vfd) return undefined;
return vfd.entry.stat(options);
},
ftruncate(fd, len) {
const vfd = getVirtualFd(fd);
if (!vfd) return undefined;
return vfd.entry.truncate(len).then(() => true);
},
fchmod: noopFd,
fchown: noopFd,
futimes: noopFd,
fdatasync: noopFd,
fsync: noopFd,
// ==================== Stream ops ====================
createReadStream(path, options) {
const pathStr = toPathStr(path);
if (pathStr !== null) {
const r = findVFSForPath(pathStr);
if (r !== null) return r.vfs.createReadStream(r.normalized, options);
}
return undefined;
},
createWriteStream(path, options) {
const pathStr = toPathStr(path);
if (pathStr !== null) {
const r = findVFSForPath(pathStr);
if (r !== null) return r.vfs.createWriteStream(r.normalized, options);
}
return undefined;
},
// ==================== Watch ops ====================
watch(filename, options, listener) {
if (typeof options === 'function') {
listener = options;
options = kEmptyObject;
} else if (options != null) {
validateObject(options, 'options');
} else {
options = kEmptyObject;
}
const pathStr = toPathStr(filename);
if (pathStr !== null) {
const r = findVFSForPath(pathStr);
if (r !== null) return r.vfs.watch(pathStr, options, listener);
}
return undefined;
},
// ==================== Async path-based ops ====================
readdir(path, options) {
const promise = vfsOp(path, (vfs, n) => vfs.promises.readdir(n, options));
if (promise !== undefined && options?.encoding === 'buffer' && !options?.withFileTypes) {
return promise.then((result) => {
for (let i = 0; i < result.length; i++) {
if (typeof result[i] === 'string') result[i] = Buffer.from(result[i]);
}
return result;
});
}
return promise;
},
lstat(path, options) {
const pathStr = toPathStr(path);
if (pathStr === null) return undefined;
const normalized = resolve(pathStr);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
if (vfs.shouldHandle(normalized)) {
return vfs.promises.lstat(normalized, options);
}
}
return undefined;
},
stat(path, options) {
const promise = vfsOp(path, (vfs, n) => vfs.promises.stat(n, options));
if (promise !== undefined && options?.throwIfNoEntry === false) {
return promise.catch((err) => {
if (err?.code === 'ENOENT') return undefined;
throw err;
});
}
return promise;
},
readFile(path, options) {
if (typeof path === 'number') {
const vfd = getVirtualFd(path);
if (vfd) {
const enc = typeof options === 'string' ? options : options?.encoding;
if (enc && enc !== 'buffer') assertEncoding(enc);
return vfd.entry.readFile(options);
}
return undefined;
}
const enc = typeof options === 'string' ? options : options?.encoding;
if (enc && enc !== 'buffer') assertEncoding(enc);
return vfsOp(path, (vfs, n) => vfs.promises.readFile(n, options));
},
realpath(path, options) {
const promise = vfsOp(path, (vfs, n) => vfs.promises.realpath(n, options));
if (promise !== undefined && options?.encoding === 'buffer') {
return promise.then((result) => Buffer.from(result));
}
return promise;
},
access(path, mode) {
return vfsOp(path, (vfs, n) => {
if (mode != null && typeof mode !== 'number') {
throw new ERR_INVALID_ARG_TYPE('mode', 'integer', mode);
}
return vfs.promises.access(n, mode).then(() => true);
});
},
readlink(path, options) {
const pathStr = toPathStr(path);
if (pathStr === null) return undefined;
const normalized = resolve(pathStr);
for (let i = 0; i < activeVFSList.length; i++) {
const vfs = activeVFSList[i];
if (vfs.shouldHandle(normalized)) {
const promise = vfs.promises.readlink(normalized, options);
if (options?.encoding === 'buffer') {
return promise.then((result) => Buffer.from(result));
}
return promise;
}
}
return undefined;
},
chown: (path, uid, gid) =>
vfsOp(path, (vfs, n) => vfs.promises.chown(n, uid, gid).then(() => true)),
lchown: (path, uid, gid) =>
vfsOp(path, (vfs, n) => vfs.promises.lchown(n, uid, gid).then(() => true)),
lutimes: (path, atime, mtime) =>
vfsOp(path, (vfs, n) => vfs.promises.lutimes(n, atime, mtime).then(() => true)),
statfs(path, options) {
const pathStr = toPathStr(path);
if (pathStr !== null && findVFSForPath(pathStr) !== null) {
if (options?.bigint) {
return {
__proto__: null,
type: 0n, bsize: 4096n, blocks: 0n,
bfree: 0n, bavail: 0n, files: 0n, ffree: 0n,
};
}
return {
__proto__: null,
type: 0, bsize: 4096, blocks: 0,
bfree: 0, bavail: 0, files: 0, ffree: 0,
};
}
return undefined;
},
writeFile(path, data, options) {
return vfsOp(path, (vfs, n) => vfs.promises.writeFile(n, data, options).then(() => true));
},
appendFile(path, data, options) {
return vfsOp(path, (vfs, n) => vfs.promises.appendFile(n, data, options).then(() => true));
},
mkdir(path, options) {
return vfsOp(path, (vfs, n) =>
vfs.promises.mkdir(n, options).then((result) => ({ __proto__: null, result })));
},
rmdir: (path) => vfsOp(path, (vfs, n) => vfs.promises.rmdir(n).then(() => true)),
rm: (path, options) => vfsOp(path, (vfs, n) => vfs.promises.rm(n, options).then(() => true)),
unlink: (path) => vfsOp(path, (vfs, n) => vfs.promises.unlink(n).then(() => true)),
rename(oldPath, newPath) {
return vfsOp(oldPath, (vfs, n) => {
checkSameVFS(n, toPathStr(newPath), 'rename', vfs);
return vfs.promises.rename(n, resolve(toPathStr(newPath))).then(() => true);
});
},
copyFile(src, dest, mode) {
return vfsOp(src, (vfs, n) => {
checkSameVFS(n, toPathStr(dest), 'copyfile', vfs);
return vfs.promises.copyFile(n, resolve(toPathStr(dest)), mode).then(() => true);
});
},
symlink(target, path, type) {
return vfsOp(path, (vfs, n) => vfs.promises.symlink(target, n, type).then(() => true));
},
truncate: (path, len) =>
vfsOp(path, (vfs, n) => vfs.promises.truncate(n, len).then(() => true)),
link(existingPath, newPath) {
return vfsOp(existingPath, (vfs, n) => {
checkSameVFS(n, toPathStr(newPath), 'link', vfs);
return vfs.promises.link(n, resolve(toPathStr(newPath))).then(() => true);
});
},
mkdtemp(prefix, options) {
const promise = vfsOp(prefix, (vfs, n) => vfs.promises.mkdtemp(n));
if (promise !== undefined && options?.encoding === 'buffer') {
return promise.then((result) => Buffer.from(result));
}
return promise;
},
chmod: (path, mode) =>
vfsOp(path, (vfs, n) => vfs.promises.chmod(n, mode).then(() => true)),
utimes: (path, atime, mtime) =>
vfsOp(path, (vfs, n) => vfs.promises.utimes(n, atime, mtime).then(() => true)),
open(path, flags, mode) {
// openSync is synchronous, so an error thrown by the provider would
// escape via fs.open's caller (instead of going through the callback).
// Catch it here and surface as a rejected promise.
return vfsOp(path, async (vfs, n) => vfs.openSync(n, flags, mode));
},
promisesOpen(path, flags, mode) {
const pathStr = toPathStr(path);
if (pathStr !== null) {
const r = findVFSForPath(pathStr);
if (r !== null) {
const fd = r.vfs.openSync(r.normalized, flags, mode);
const vfd = getVirtualFd(fd);
return PromiseResolve(vfd.entry);
}
}
return undefined;
},
lchmod: (path, mode) =>
vfsOp(path, (vfs, n) => vfs.promises.lchmod(n, mode).then(() => true)),
};
}
module.exports = {
registerVFS,
deregisterVFS,
};