Adds the node:vfs builtin module with VirtualFileSystem and provider classes. No integration with fs, modules, or SEA. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: https://github.com/nodejs/node/pull/63115 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: Robert Nagy <ronagy@icloud.com> Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
477 lines
14 KiB
JavaScript
477 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
Promise,
|
|
StringPrototypeStartsWith,
|
|
} = primordials;
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { VirtualProvider } = require('internal/vfs/provider');
|
|
const { VirtualFileHandle } = require('internal/vfs/file_handle');
|
|
const { getValidatedPath } = require('internal/fs/utils');
|
|
const { setOwnProperty } = require('internal/util');
|
|
const {
|
|
createEACCES,
|
|
createEBADF,
|
|
createENOENT,
|
|
} = require('internal/vfs/errors');
|
|
|
|
/**
|
|
* A file handle that wraps a real file descriptor.
|
|
*/
|
|
// TODO(mcollina): reuse FileHandle from internal/fs/promises for the async
|
|
// methods instead of manually wrapping fs.read/write/fstat/ftruncate/close in
|
|
// Promises. Blocked on a way to wrap an existing numeric fd in a FileHandle so
|
|
// sync-opened handles can still share one underlying handle for async ops.
|
|
class RealFileHandle extends VirtualFileHandle {
|
|
#fd;
|
|
#realPath;
|
|
|
|
#checkClosed(syscall) {
|
|
if (this.closed) {
|
|
throw createEBADF(syscall);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} path The VFS path
|
|
* @param {string} flags The open flags
|
|
* @param {number} mode The file mode
|
|
* @param {number} fd The real file descriptor
|
|
* @param {string} realPath The real filesystem path
|
|
*/
|
|
constructor(path, flags, mode, fd, realPath) {
|
|
super(path, flags, mode);
|
|
this.#fd = fd;
|
|
this.#realPath = realPath;
|
|
}
|
|
|
|
readSync(buffer, offset, length, position) {
|
|
this.#checkClosed('read');
|
|
return fs.readSync(this.#fd, buffer, offset, length, position);
|
|
}
|
|
|
|
async read(buffer, offset, length, position) {
|
|
this.#checkClosed('read');
|
|
return new Promise((resolve, reject) => {
|
|
fs.read(this.#fd, buffer, offset, length, position, (err, bytesRead) => {
|
|
if (err) reject(err);
|
|
else resolve({ __proto__: null, bytesRead, buffer });
|
|
});
|
|
});
|
|
}
|
|
|
|
writeSync(buffer, offset, length, position) {
|
|
this.#checkClosed('write');
|
|
return fs.writeSync(this.#fd, buffer, offset, length, position);
|
|
}
|
|
|
|
async write(buffer, offset, length, position) {
|
|
this.#checkClosed('write');
|
|
return new Promise((resolve, reject) => {
|
|
fs.write(this.#fd, buffer, offset, length, position, (err, bytesWritten) => {
|
|
if (err) reject(err);
|
|
else resolve({ __proto__: null, bytesWritten, buffer });
|
|
});
|
|
});
|
|
}
|
|
|
|
readFileSync(options) {
|
|
this.#checkClosed('read');
|
|
return fs.readFileSync(this.#realPath, options);
|
|
}
|
|
|
|
async readFile(options) {
|
|
this.#checkClosed('read');
|
|
return fs.promises.readFile(this.#realPath, options);
|
|
}
|
|
|
|
writeFileSync(data, options) {
|
|
this.#checkClosed('write');
|
|
fs.writeFileSync(this.#realPath, data, options);
|
|
}
|
|
|
|
async writeFile(data, options) {
|
|
this.#checkClosed('write');
|
|
return fs.promises.writeFile(this.#realPath, data, options);
|
|
}
|
|
|
|
statSync(options) {
|
|
this.#checkClosed('fstat');
|
|
return fs.fstatSync(this.#fd, options);
|
|
}
|
|
|
|
async stat(options) {
|
|
this.#checkClosed('fstat');
|
|
return new Promise((resolve, reject) => {
|
|
fs.fstat(this.#fd, options, (err, stats) => {
|
|
if (err) reject(err);
|
|
else resolve(stats);
|
|
});
|
|
});
|
|
}
|
|
|
|
truncateSync(len = 0) {
|
|
this.#checkClosed('ftruncate');
|
|
fs.ftruncateSync(this.#fd, len);
|
|
}
|
|
|
|
async truncate(len = 0) {
|
|
this.#checkClosed('ftruncate');
|
|
return new Promise((resolve, reject) => {
|
|
fs.ftruncate(this.#fd, len, (err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
closeSync() {
|
|
if (!this.closed) {
|
|
fs.closeSync(this.#fd);
|
|
super.closeSync();
|
|
}
|
|
}
|
|
|
|
async close() {
|
|
if (!this.closed) {
|
|
return new Promise((resolve, reject) => {
|
|
fs.close(this.#fd, (err) => {
|
|
if (err) reject(err);
|
|
else {
|
|
super.closeSync();
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A provider that wraps a real filesystem directory.
|
|
* Allows mounting a real directory at a different VFS path.
|
|
*/
|
|
class RealFSProvider extends VirtualProvider {
|
|
#rootPath;
|
|
|
|
/**
|
|
* @param {string} rootPath The real filesystem path to use as root
|
|
*/
|
|
constructor(rootPath) {
|
|
super();
|
|
// Resolve to absolute path and normalize
|
|
this.#rootPath = path.resolve(getValidatedPath(rootPath, 'rootPath'));
|
|
setOwnProperty(this, 'readonly', false);
|
|
setOwnProperty(this, 'supportsSymlinks', true);
|
|
}
|
|
|
|
/**
|
|
* Gets the root path of this provider.
|
|
* @returns {string}
|
|
*/
|
|
get rootPath() {
|
|
return this.#rootPath;
|
|
}
|
|
|
|
/**
|
|
* Resolves a VFS path to a real filesystem path.
|
|
* Ensures the path doesn't escape the root directory.
|
|
* @param {string} vfsPath The VFS path (relative to provider root)
|
|
* @returns {string} The real filesystem path
|
|
* @private
|
|
*/
|
|
#resolvePath(vfsPath, followSymlinks = true) {
|
|
// Normalize the VFS path (remove leading slash, handle . and ..)
|
|
let normalized = vfsPath;
|
|
if (normalized.startsWith('/')) {
|
|
normalized = normalized.slice(1);
|
|
}
|
|
|
|
// Join with root and resolve
|
|
const realPath = path.resolve(this.#rootPath, normalized);
|
|
|
|
// Security check: ensure the resolved path is within rootPath
|
|
const rootWithSep = this.#rootPath.endsWith(path.sep) ?
|
|
this.#rootPath :
|
|
this.#rootPath + path.sep;
|
|
|
|
if (realPath !== this.#rootPath && !StringPrototypeStartsWith(realPath, rootWithSep)) {
|
|
throw createENOENT('open', vfsPath);
|
|
}
|
|
|
|
// Resolve symlinks to prevent escape via symbolic links
|
|
if (followSymlinks) {
|
|
try {
|
|
const resolved = fs.realpathSync(realPath);
|
|
if (resolved !== this.#rootPath &&
|
|
!StringPrototypeStartsWith(resolved, rootWithSep)) {
|
|
throw createENOENT('open', vfsPath);
|
|
}
|
|
return resolved;
|
|
} catch (err) {
|
|
if (err?.code !== 'ENOENT') throw err;
|
|
// Path doesn't exist yet - verify deepest existing ancestor
|
|
this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath);
|
|
return realPath;
|
|
}
|
|
}
|
|
|
|
// For lstat/readlink (no final symlink follow), check parent only
|
|
this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath);
|
|
return realPath;
|
|
}
|
|
|
|
/**
|
|
* Verifies that the deepest existing ancestor of a path is within rootPath.
|
|
* @param {string} realPath The real filesystem path
|
|
* @param {string} rootWithSep The rootPath with trailing separator
|
|
* @param {string} vfsPath The original VFS path (for error messages)
|
|
*/
|
|
#verifyAncestorInRoot(realPath, rootWithSep, vfsPath) {
|
|
let current = path.dirname(realPath);
|
|
while (current.length >= this.#rootPath.length) {
|
|
try {
|
|
const resolved = fs.realpathSync(current);
|
|
if (resolved !== this.#rootPath &&
|
|
!StringPrototypeStartsWith(resolved, rootWithSep)) {
|
|
throw createENOENT('open', vfsPath);
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
if (err?.code !== 'ENOENT') throw err;
|
|
current = path.dirname(current);
|
|
}
|
|
}
|
|
}
|
|
|
|
openSync(vfsPath, flags, mode) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
const fd = fs.openSync(realPath, flags, mode);
|
|
return new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath);
|
|
}
|
|
|
|
async open(vfsPath, flags, mode) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return new Promise((resolve, reject) => {
|
|
fs.open(realPath, flags, mode, (err, fd) => {
|
|
if (err) reject(err);
|
|
else resolve(new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath));
|
|
});
|
|
});
|
|
}
|
|
|
|
statSync(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.statSync(realPath, options);
|
|
}
|
|
|
|
async stat(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.promises.stat(realPath, options);
|
|
}
|
|
|
|
lstatSync(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath, false);
|
|
return fs.lstatSync(realPath, options);
|
|
}
|
|
|
|
async lstat(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath, false);
|
|
return fs.promises.lstat(realPath, options);
|
|
}
|
|
|
|
readdirSync(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.readdirSync(realPath, options);
|
|
}
|
|
|
|
async readdir(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.promises.readdir(realPath, options);
|
|
}
|
|
|
|
mkdirSync(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.mkdirSync(realPath, options);
|
|
}
|
|
|
|
async mkdir(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.promises.mkdir(realPath, options);
|
|
}
|
|
|
|
rmdirSync(vfsPath) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
fs.rmdirSync(realPath);
|
|
}
|
|
|
|
async rmdir(vfsPath) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.promises.rmdir(realPath);
|
|
}
|
|
|
|
unlinkSync(vfsPath) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
fs.unlinkSync(realPath);
|
|
}
|
|
|
|
async unlink(vfsPath) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.promises.unlink(realPath);
|
|
}
|
|
|
|
renameSync(oldVfsPath, newVfsPath) {
|
|
const oldRealPath = this.#resolvePath(oldVfsPath);
|
|
const newRealPath = this.#resolvePath(newVfsPath);
|
|
fs.renameSync(oldRealPath, newRealPath);
|
|
}
|
|
|
|
async rename(oldVfsPath, newVfsPath) {
|
|
const oldRealPath = this.#resolvePath(oldVfsPath);
|
|
const newRealPath = this.#resolvePath(newVfsPath);
|
|
return fs.promises.rename(oldRealPath, newRealPath);
|
|
}
|
|
|
|
readlinkSync(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath, false);
|
|
const target = fs.readlinkSync(realPath, options);
|
|
// Translate absolute targets within rootPath to VFS-relative
|
|
if (path.isAbsolute(target)) {
|
|
const rootWithSep = this.#rootPath + path.sep;
|
|
if (target === this.#rootPath) {
|
|
return '/';
|
|
}
|
|
if (StringPrototypeStartsWith(target, rootWithSep)) {
|
|
return '/' + target.slice(rootWithSep.length).replace(/\\/g, '/');
|
|
}
|
|
}
|
|
return target;
|
|
}
|
|
|
|
async readlink(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath, false);
|
|
const target = await fs.promises.readlink(realPath, options);
|
|
// Translate absolute targets within rootPath to VFS-relative
|
|
if (path.isAbsolute(target)) {
|
|
const rootWithSep = this.#rootPath + path.sep;
|
|
if (target === this.#rootPath) {
|
|
return '/';
|
|
}
|
|
if (StringPrototypeStartsWith(target, rootWithSep)) {
|
|
return '/' + target.slice(rootWithSep.length).replace(/\\/g, '/');
|
|
}
|
|
}
|
|
return target;
|
|
}
|
|
|
|
symlinkSync(target, vfsPath, type) {
|
|
// Validate target resolves within rootPath
|
|
if (path.isAbsolute(target)) {
|
|
throw createEACCES('symlink', vfsPath);
|
|
}
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
const resolvedTarget = path.resolve(path.dirname(realPath), target);
|
|
const rootWithSep = this.#rootPath.endsWith(path.sep) ?
|
|
this.#rootPath : this.#rootPath + path.sep;
|
|
if (resolvedTarget !== this.#rootPath &&
|
|
!StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
|
|
throw createEACCES('symlink', vfsPath);
|
|
}
|
|
fs.symlinkSync(target, realPath, type);
|
|
}
|
|
|
|
async symlink(target, vfsPath, type) {
|
|
// Validate target resolves within rootPath
|
|
if (path.isAbsolute(target)) {
|
|
throw createEACCES('symlink', vfsPath);
|
|
}
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
const resolvedTarget = path.resolve(path.dirname(realPath), target);
|
|
const rootWithSep = this.#rootPath.endsWith(path.sep) ?
|
|
this.#rootPath : this.#rootPath + path.sep;
|
|
if (resolvedTarget !== this.#rootPath &&
|
|
!StringPrototypeStartsWith(resolvedTarget, rootWithSep)) {
|
|
throw createEACCES('symlink', vfsPath);
|
|
}
|
|
return fs.promises.symlink(target, realPath, type);
|
|
}
|
|
|
|
// path.relative handles case-insensitivity on Windows, which matters here
|
|
// because fs.realpathSync (a JS impl) preserves case but fs.promises.realpath
|
|
// (native) canonicalizes the drive letter and other components.
|
|
#resolvedToVfsPath(resolved, vfsPath, syscall) {
|
|
const rel = path.relative(this.#rootPath, resolved);
|
|
if (rel === '') return '/';
|
|
if (rel === '..' ||
|
|
StringPrototypeStartsWith(rel, '..' + path.sep) ||
|
|
path.isAbsolute(rel)) {
|
|
throw createEACCES(syscall, vfsPath);
|
|
}
|
|
return '/' + rel.replace(/\\/g, '/');
|
|
}
|
|
|
|
realpathSync(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
const resolved = fs.realpathSync(realPath, options);
|
|
return this.#resolvedToVfsPath(resolved, vfsPath, 'realpath');
|
|
}
|
|
|
|
async realpath(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
const resolved = await fs.promises.realpath(realPath, options);
|
|
return this.#resolvedToVfsPath(resolved, vfsPath, 'realpath');
|
|
}
|
|
|
|
accessSync(vfsPath, mode) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
fs.accessSync(realPath, mode);
|
|
}
|
|
|
|
async access(vfsPath, mode) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.promises.access(realPath, mode);
|
|
}
|
|
|
|
copyFileSync(srcVfsPath, destVfsPath, mode) {
|
|
const srcRealPath = this.#resolvePath(srcVfsPath);
|
|
const destRealPath = this.#resolvePath(destVfsPath);
|
|
fs.copyFileSync(srcRealPath, destRealPath, mode);
|
|
}
|
|
|
|
async copyFile(srcVfsPath, destVfsPath, mode) {
|
|
const srcRealPath = this.#resolvePath(srcVfsPath);
|
|
const destRealPath = this.#resolvePath(destVfsPath);
|
|
return fs.promises.copyFile(srcRealPath, destRealPath, mode);
|
|
}
|
|
|
|
get supportsWatch() {
|
|
return true;
|
|
}
|
|
|
|
watch(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.watch(realPath, options);
|
|
}
|
|
|
|
watchAsync(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.promises.watch(realPath, options);
|
|
}
|
|
|
|
watchFile(vfsPath, options) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
return fs.watchFile(realPath, options, () => {});
|
|
}
|
|
|
|
unwatchFile(vfsPath, listener) {
|
|
const realPath = this.#resolvePath(vfsPath);
|
|
fs.unwatchFile(realPath, listener);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
RealFSProvider,
|
|
RealFileHandle,
|
|
};
|