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>
618 lines
16 KiB
JavaScript
618 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
ERR_METHOD_NOT_IMPLEMENTED,
|
|
} = require('internal/errors').codes;
|
|
|
|
const {
|
|
createEROFS,
|
|
createEEXIST,
|
|
createEACCES,
|
|
} = require('internal/vfs/errors');
|
|
|
|
const {
|
|
fs: {
|
|
R_OK,
|
|
W_OK,
|
|
X_OK,
|
|
COPYFILE_EXCL,
|
|
},
|
|
} = internalBinding('constants');
|
|
|
|
/**
|
|
* Base class for VFS providers.
|
|
* Providers implement the essential primitives that the VFS delegates to.
|
|
*
|
|
* Implementations must override the essential primitives (open, stat, readdir, etc.)
|
|
* Default implementations for derived methods (readFile, writeFile, etc.) are provided.
|
|
*/
|
|
class VirtualProvider {
|
|
// === CAPABILITY FLAGS ===
|
|
|
|
/**
|
|
* Returns true if this provider is read-only.
|
|
* @returns {boolean}
|
|
*/
|
|
get readonly() {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this provider supports symbolic links.
|
|
* @returns {boolean}
|
|
*/
|
|
get supportsSymlinks() {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this provider supports file watching.
|
|
* @returns {boolean}
|
|
*/
|
|
get supportsWatch() {
|
|
return false;
|
|
}
|
|
|
|
// === ESSENTIAL PRIMITIVES (must be implemented by subclasses) ===
|
|
|
|
/**
|
|
* Opens a file and returns a file handle.
|
|
* @param {string} path The file path (relative to provider root)
|
|
* @param {string} flags The open flags ('r', 'r+', 'w', 'w+', 'a', 'a+')
|
|
* @param {number} [mode] The file mode (for creating files)
|
|
* @returns {Promise<VirtualFileHandle>}
|
|
*/
|
|
async open(path, flags, mode) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('open');
|
|
}
|
|
|
|
/**
|
|
* Opens a file synchronously and returns a file handle.
|
|
* @param {string} path The file path (relative to provider root)
|
|
* @param {string} flags The open flags ('r', 'r+', 'w', 'w+', 'a', 'a+')
|
|
* @param {number} [mode] The file mode (for creating files)
|
|
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
|
|
*/
|
|
openSync(path, flags, mode) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('openSync');
|
|
}
|
|
|
|
/**
|
|
* Gets stats for a path.
|
|
* @param {string} path The path to stat
|
|
* @param {object} [options] Options
|
|
* @returns {Promise<Stats>}
|
|
*/
|
|
async stat(path, options) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('stat');
|
|
}
|
|
|
|
/**
|
|
* Gets stats for a path synchronously.
|
|
* @param {string} path The path to stat
|
|
* @param {object} [options] Options
|
|
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
|
|
*/
|
|
statSync(path, options) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('statSync');
|
|
}
|
|
|
|
/**
|
|
* Gets stats for a path without following symlinks.
|
|
* @param {string} path The path to stat
|
|
* @param {object} [options] Options
|
|
* @returns {Promise<Stats>}
|
|
*/
|
|
async lstat(path, options) {
|
|
// Default: same as stat (for providers that don't support symlinks)
|
|
return this.stat(path, options);
|
|
}
|
|
|
|
/**
|
|
* Gets stats for a path synchronously without following symlinks.
|
|
* @param {string} path The path to stat
|
|
* @param {object} [options] Options
|
|
* @returns {Stats}
|
|
*/
|
|
lstatSync(path, options) {
|
|
// Default: same as statSync (for providers that don't support symlinks)
|
|
return this.statSync(path, options);
|
|
}
|
|
|
|
/**
|
|
* Reads directory contents.
|
|
* @param {string} path The directory path
|
|
* @param {object} [options] Options
|
|
* @returns {Promise<string[]|Dirent[]>}
|
|
*/
|
|
async readdir(path, options) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('readdir');
|
|
}
|
|
|
|
/**
|
|
* Reads directory contents synchronously.
|
|
* @param {string} path The directory path
|
|
* @param {object} [options] Options
|
|
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
|
|
*/
|
|
readdirSync(path, options) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('readdirSync');
|
|
}
|
|
|
|
/**
|
|
* Creates a directory.
|
|
* @param {string} path The directory path
|
|
* @param {object} [options] Options
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async mkdir(path, options) {
|
|
if (this.readonly) {
|
|
throw createEROFS('mkdir', path);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('mkdir');
|
|
}
|
|
|
|
/**
|
|
* Creates a directory synchronously.
|
|
* @param {string} path The directory path
|
|
* @param {object} [options] Options
|
|
*/
|
|
mkdirSync(path, options) {
|
|
if (this.readonly) {
|
|
throw createEROFS('mkdir', path);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('mkdirSync');
|
|
}
|
|
|
|
/**
|
|
* Removes a directory.
|
|
* @param {string} path The directory path
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async rmdir(path) {
|
|
if (this.readonly) {
|
|
throw createEROFS('rmdir', path);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('rmdir');
|
|
}
|
|
|
|
/**
|
|
* Removes a directory synchronously.
|
|
* @param {string} path The directory path
|
|
*/
|
|
rmdirSync(path) {
|
|
if (this.readonly) {
|
|
throw createEROFS('rmdir', path);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('rmdirSync');
|
|
}
|
|
|
|
/**
|
|
* Removes a file.
|
|
* @param {string} path The file path
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async unlink(path) {
|
|
if (this.readonly) {
|
|
throw createEROFS('unlink', path);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('unlink');
|
|
}
|
|
|
|
/**
|
|
* Removes a file synchronously.
|
|
* @param {string} path The file path
|
|
*/
|
|
unlinkSync(path) {
|
|
if (this.readonly) {
|
|
throw createEROFS('unlink', path);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('unlinkSync');
|
|
}
|
|
|
|
/**
|
|
* Renames a file or directory.
|
|
* @param {string} oldPath The old path
|
|
* @param {string} newPath The new path
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async rename(oldPath, newPath) {
|
|
if (this.readonly) {
|
|
throw createEROFS('rename', oldPath);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('rename');
|
|
}
|
|
|
|
/**
|
|
* Renames a file or directory synchronously.
|
|
* @param {string} oldPath The old path
|
|
* @param {string} newPath The new path
|
|
*/
|
|
renameSync(oldPath, newPath) {
|
|
if (this.readonly) {
|
|
throw createEROFS('rename', oldPath);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('renameSync');
|
|
}
|
|
|
|
// === DEFAULT IMPLEMENTATIONS (built on primitives) ===
|
|
|
|
/**
|
|
* Reads a file.
|
|
* @param {string} path The file path
|
|
* @param {object|string} [options] Options or encoding
|
|
* @returns {Promise<Buffer|string>}
|
|
*/
|
|
async readFile(path, options) {
|
|
const flag = (typeof options === 'object' && options !== null) ?
|
|
(options.flag ?? 'r') : 'r';
|
|
const handle = await this.open(path, flag);
|
|
try {
|
|
return await handle.readFile(options);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads a file synchronously.
|
|
* @param {string} path The file path
|
|
* @param {object|string} [options] Options or encoding
|
|
* @returns {Buffer|string}
|
|
*/
|
|
readFileSync(path, options) {
|
|
const flag = (typeof options === 'object' && options !== null) ?
|
|
(options.flag ?? 'r') : 'r';
|
|
const handle = this.openSync(path, flag);
|
|
try {
|
|
return handle.readFileSync(options);
|
|
} finally {
|
|
handle.closeSync();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes a file.
|
|
* @param {string} path The file path
|
|
* @param {Buffer|string} data The data to write
|
|
* @param {object} [options] Options
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async writeFile(path, data, options) {
|
|
if (this.readonly) {
|
|
throw createEROFS('open', path);
|
|
}
|
|
const flag = options?.flag ?? 'w';
|
|
const handle = await this.open(path, flag, options?.mode);
|
|
try {
|
|
await handle.writeFile(data, options);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes a file synchronously.
|
|
* @param {string} path The file path
|
|
* @param {Buffer|string} data The data to write
|
|
* @param {object} [options] Options
|
|
*/
|
|
writeFileSync(path, data, options) {
|
|
if (this.readonly) {
|
|
throw createEROFS('open', path);
|
|
}
|
|
const flag = options?.flag ?? 'w';
|
|
const handle = this.openSync(path, flag, options?.mode);
|
|
try {
|
|
handle.writeFileSync(data, options);
|
|
} finally {
|
|
handle.closeSync();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Appends to a file.
|
|
* @param {string} path The file path
|
|
* @param {Buffer|string} data The data to append
|
|
* @param {object} [options] Options
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async appendFile(path, data, options) {
|
|
if (this.readonly) {
|
|
throw createEROFS('open', path);
|
|
}
|
|
const flag = options?.flag ?? 'a';
|
|
const handle = await this.open(path, flag, options?.mode);
|
|
try {
|
|
await handle.writeFile(data, options);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Appends to a file synchronously.
|
|
* @param {string} path The file path
|
|
* @param {Buffer|string} data The data to append
|
|
* @param {object} [options] Options
|
|
*/
|
|
appendFileSync(path, data, options) {
|
|
if (this.readonly) {
|
|
throw createEROFS('open', path);
|
|
}
|
|
const flag = options?.flag ?? 'a';
|
|
const handle = this.openSync(path, flag, options?.mode);
|
|
try {
|
|
handle.writeFileSync(data, options);
|
|
} finally {
|
|
handle.closeSync();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a path exists.
|
|
* @param {string} path The path to check
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async exists(path) {
|
|
try {
|
|
await this.stat(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a path exists synchronously.
|
|
* @param {string} path The path to check
|
|
* @returns {boolean}
|
|
*/
|
|
existsSync(path) {
|
|
try {
|
|
this.statSync(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copies a file.
|
|
* @param {string} src Source path
|
|
* @param {string} dest Destination path
|
|
* @param {number} [mode] Copy mode flags
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async copyFile(src, dest, mode) {
|
|
if (this.readonly) {
|
|
throw createEROFS('copyfile', dest);
|
|
}
|
|
if ((mode & COPYFILE_EXCL) !== 0) {
|
|
if (await this.exists(dest)) {
|
|
throw createEEXIST('copyfile', dest);
|
|
}
|
|
}
|
|
const content = await this.readFile(src);
|
|
await this.writeFile(dest, content);
|
|
}
|
|
|
|
/**
|
|
* Copies a file synchronously.
|
|
* @param {string} src Source path
|
|
* @param {string} dest Destination path
|
|
* @param {number} [mode] Copy mode flags
|
|
*/
|
|
copyFileSync(src, dest, mode) {
|
|
if (this.readonly) {
|
|
throw createEROFS('copyfile', dest);
|
|
}
|
|
if ((mode & COPYFILE_EXCL) !== 0) {
|
|
if (this.existsSync(dest)) {
|
|
throw createEEXIST('copyfile', dest);
|
|
}
|
|
}
|
|
const content = this.readFileSync(src);
|
|
this.writeFileSync(dest, content);
|
|
}
|
|
|
|
/**
|
|
* Gets the real path by resolving symlinks.
|
|
* @param {string} path The path
|
|
* @param {object} [options] Options
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async realpath(path, options) {
|
|
// Default: return the path as-is (for providers without symlinks)
|
|
// First verify the path exists
|
|
await this.stat(path);
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Gets the real path synchronously.
|
|
* @param {string} path The path
|
|
* @param {object} [options] Options
|
|
* @returns {string}
|
|
*/
|
|
realpathSync(path, options) {
|
|
// Default: return the path as-is (for providers without symlinks)
|
|
// First verify the path exists
|
|
this.statSync(path);
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Checks file accessibility.
|
|
* @param {string} path The path to check
|
|
* @param {number} [mode] Access mode
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async access(path, mode) {
|
|
const stats = await this.stat(path);
|
|
this.#checkAccessMode(path, stats, mode);
|
|
}
|
|
|
|
/**
|
|
* Checks file accessibility synchronously.
|
|
* @param {string} path The path to check
|
|
* @param {number} [mode] Access mode
|
|
*/
|
|
accessSync(path, mode) {
|
|
const stats = this.statSync(path);
|
|
this.#checkAccessMode(path, stats, mode);
|
|
}
|
|
|
|
/**
|
|
* Checks access mode bits against file stats.
|
|
* @param {string} path The path (for error messages)
|
|
* @param {Stats} stats The file stats
|
|
* @param {number} mode The requested access mode
|
|
*/
|
|
#checkAccessMode(path, stats, mode) {
|
|
if (mode == null || mode === 0) return; // F_OK = 0, existence-only check
|
|
|
|
const fileMode = stats.mode & 0o777; // Permission bits
|
|
// Check owner permissions (simplified: treat VFS user as owner)
|
|
if ((mode & R_OK) !== 0 && (fileMode & 0o400) === 0) {
|
|
throw createEACCES('access', path);
|
|
}
|
|
if ((mode & W_OK) !== 0 && (fileMode & 0o200) === 0) {
|
|
throw createEACCES('access', path);
|
|
}
|
|
if ((mode & X_OK) !== 0 && (fileMode & 0o100) === 0) {
|
|
throw createEACCES('access', path);
|
|
}
|
|
}
|
|
|
|
// === HARD LINK OPERATIONS (optional) ===
|
|
|
|
/**
|
|
* Creates a hard link.
|
|
* @param {string} existingPath The existing file path
|
|
* @param {string} newPath The new link path
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async link(existingPath, newPath) {
|
|
if (this.readonly) {
|
|
throw createEROFS('link', newPath);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('link');
|
|
}
|
|
|
|
/**
|
|
* Creates a hard link synchronously.
|
|
* @param {string} existingPath The existing file path
|
|
* @param {string} newPath The new link path
|
|
*/
|
|
linkSync(existingPath, newPath) {
|
|
if (this.readonly) {
|
|
throw createEROFS('link', newPath);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('linkSync');
|
|
}
|
|
|
|
// === SYMLINK OPERATIONS (optional, throw ENOENT by default) ===
|
|
|
|
/**
|
|
* Reads the target of a symbolic link.
|
|
* @param {string} path The symlink path
|
|
* @param {object} [options] Options
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async readlink(path, options) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('readlink');
|
|
}
|
|
|
|
/**
|
|
* Reads the target of a symbolic link synchronously.
|
|
* @param {string} path The symlink path
|
|
* @param {object} [options] Options
|
|
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass
|
|
*/
|
|
readlinkSync(path, options) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('readlinkSync');
|
|
}
|
|
|
|
/**
|
|
* Creates a symbolic link.
|
|
* @param {string} target The symlink target
|
|
* @param {string} path The symlink path
|
|
* @param {string} [type] The symlink type (file, dir, junction)
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async symlink(target, path, type) {
|
|
if (this.readonly) {
|
|
throw createEROFS('symlink', path);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('symlink');
|
|
}
|
|
|
|
/**
|
|
* Creates a symbolic link synchronously.
|
|
* @param {string} target The symlink target
|
|
* @param {string} path The symlink path
|
|
* @param {string} [type] The symlink type (file, dir, junction)
|
|
*/
|
|
symlinkSync(target, path, type) {
|
|
if (this.readonly) {
|
|
throw createEROFS('symlink', path);
|
|
}
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('symlinkSync');
|
|
}
|
|
|
|
// === WATCH OPERATIONS (optional, polling-based) ===
|
|
|
|
/**
|
|
* Watches a file or directory for changes.
|
|
* Returns an EventEmitter-like object that emits 'change' and 'close' events.
|
|
* @param {string} path The path to watch
|
|
* @param {object} [options] Watch options
|
|
* @param {number} [options.interval] Polling interval in ms (default: 100)
|
|
* @param {boolean} [options.recursive] Watch subdirectories (default: false)
|
|
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass
|
|
*/
|
|
watch(path, options) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('watch');
|
|
}
|
|
|
|
/**
|
|
* Watches a file or directory for changes (async iterable version).
|
|
* Used by fs.promises.watch().
|
|
* @param {string} path The path to watch
|
|
* @param {object} [options] Watch options
|
|
* @param {number} [options.interval] Polling interval in ms (default: 100)
|
|
* @param {boolean} [options.recursive] Watch subdirectories (default: false)
|
|
* @param {AbortSignal} [options.signal] AbortSignal for cancellation
|
|
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass
|
|
*/
|
|
watchAsync(path, options) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('watchAsync');
|
|
}
|
|
|
|
/**
|
|
* Watches a file for changes using stat polling.
|
|
* Returns a StatWatcher-like object that emits 'change' events with stats.
|
|
* @param {string} path The path to watch
|
|
* @param {object} [options] Watch options
|
|
* @param {number} [options.interval] Polling interval in ms (default: 5007)
|
|
* @param {boolean} [options.persistent] Whether the watcher should prevent exit
|
|
* @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass
|
|
*/
|
|
watchFile(path, options) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('watchFile');
|
|
}
|
|
|
|
/**
|
|
* Stops watching a file for changes.
|
|
* @param {string} path The path to stop watching
|
|
* @param {Function} [listener] Optional listener to remove
|
|
*/
|
|
unwatchFile(path, listener) {
|
|
throw new ERR_METHOD_NOT_IMPLEMENTED('unwatchFile');
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
VirtualProvider,
|
|
};
|