'use strict';
const {
ArrayPrototypePush,
MathMin,
Promise,
StringPrototypeStartsWith,
} = primordials;
const { Buffer } = require('buffer');
const fs = require('fs');
const path = require('path');
const { VirtualProvider } = require('internal/vfs/provider');
const { VirtualFileHandle } = require('internal/vfs/file_handle');
const {
constants: { kWriteFileMaxChunkSize },
getOptions,
getValidatedPath,
} = require('internal/fs/utils');
const { isIterable } = require('internal/streams/utils');
const { isArrayBufferView } = require('internal/util/types');
const { setOwnProperty } = require('internal/util');
const { parseFileMode, validateBoolean } = require('internal/validators');
const {
AbortError,
codes: {
ERR_METHOD_NOT_IMPLEMENTED,
},
} = require('internal/errors');
const {
createEACCES,
createEBADF,
createENOENT,
} = require('internal/vfs/errors');
const kReadFileUnknownBufferLength = 8192;
function isCustomIterable(obj) {
return isIterable(obj) && !isArrayBufferView(obj) && typeof obj !== 'string';
}
function checkAborted(signal) {
if (signal?.aborted) {
throw new AbortError(undefined, { cause: signal.reason });
}
}
/**
* 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;
#checkClosed(syscall) {
if (this.closed) {
throw createEBADF(syscall);
}
}
#readFileResult(buffer, bytesRead, options) {
buffer = buffer.subarray(0, bytesRead);
const encoding = typeof options === 'string' ? options : options?.encoding;
if (encoding && encoding !== 'buffer') {
buffer = buffer.toString(encoding);
}
return buffer;
}
#readFileUnknownSizeResult(buffers, totalRead, options) {
return this.#readFileResult(
Buffer.concat(buffers, totalRead), totalRead, options);
}
/**
* @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
*/
constructor(path, flags, mode, fd) {
super(path, flags, mode);
this.#fd = fd;
}
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');
const size = fs.fstatSync(this.#fd).size;
if (size === 0) {
const buffers = [];
let totalRead = 0;
while (true) {
const buffer = Buffer.allocUnsafe(kReadFileUnknownBufferLength);
const read = fs.readSync(
this.#fd, buffer, 0, buffer.byteLength, totalRead);
if (read === 0) break;
ArrayPrototypePush(buffers, buffer.subarray(0, read));
totalRead += read;
}
return this.#readFileUnknownSizeResult(buffers, totalRead, options);
}
const buffer = Buffer.allocUnsafe(size);
let bytesRead = 0;
while (bytesRead < buffer.byteLength) {
const read = fs.readSync(
this.#fd,
buffer,
bytesRead,
buffer.byteLength - bytesRead,
bytesRead,
);
if (read === 0) break;
bytesRead += read;
}
return this.#readFileResult(buffer, bytesRead, options);
}
async readFile(options) {
this.#checkClosed('read');
const size = (await this.stat()).size;
if (size === 0) {
const buffers = [];
let totalRead = 0;
while (true) {
const buffer = Buffer.allocUnsafe(kReadFileUnknownBufferLength);
const { bytesRead: read } = await this.read(
buffer,
0,
buffer.byteLength,
totalRead,
);
if (read === 0) break;
ArrayPrototypePush(buffers, buffer.subarray(0, read));
totalRead += read;
}
return this.#readFileUnknownSizeResult(buffers, totalRead, options);
}
const buffer = Buffer.allocUnsafe(size);
let bytesRead = 0;
while (bytesRead < buffer.byteLength) {
const { bytesRead: read } = await this.read(
buffer,
bytesRead,
buffer.byteLength - bytesRead,
bytesRead,
);
if (read === 0) break;
bytesRead += read;
}
return this.#readFileResult(buffer, bytesRead, options);
}
writeFileSync(data, options) {
this.#checkClosed('write');
fs.writeFileSync(this.#fd, data, options);
}
// Writes the whole buffer at the descriptor's current position, at most
// kWriteFileMaxChunkSize per call, the way writeFileHandle() does.
async #writeAll(buffer, signal) {
let written = 0;
while (written < buffer.byteLength) {
checkAborted(signal);
const { bytesWritten } = await this.write(
buffer,
written,
MathMin(kWriteFileMaxChunkSize, buffer.byteLength - written),
null);
written += bytesWritten;
}
}
#fsync() {
this.#checkClosed('fsync');
return new Promise((resolve, reject) => {
fs.fsync(this.#fd, (err) => {
if (err) reject(err);
else resolve();
});
});
}
async writeFile(data, options) {
this.#checkClosed('write');
if (!isCustomIterable(data)) {
return new Promise((resolve, reject) => {
fs.writeFile(this.#fd, data, options, (err) => {
if (err) reject(err);
else resolve();
});
});
}
// The chunks are written one at a time through the descriptor, the way
// writeFileHandle() does.
// `flush` is not part of that: `filehandle.writeFile()` ignores it, but
// reopening the path used to fsync once at the end, so that is kept here
// instead of being multiplied by the number of chunks.
const opts = getOptions(options, {
encoding: 'utf8',
mode: 0o666,
flush: false,
});
const flush = opts.flush ?? false;
validateBoolean(flush, 'options.flush');
parseFileMode(opts.mode, 'mode', 0o666);
// An already aborted signal must not end up waiting on a source that
// never yields, so it is read before the first next().
checkAborted(opts.signal);
const encoding = opts.encoding || 'utf8';
for await (const chunk of data) {
await this.#writeAll(
isArrayBufferView(chunk) ? chunk : Buffer.from(chunk, encoding),
opts.signal);
// An abort that arrived while the write was in flight must surface
// before the source is asked for another chunk.
checkAborted(opts.signal);
}
if (flush) await this.#fsync();
}
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);
}
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));
});
});
}
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);
}
lchmodSync(vfsPath, mode) {
if (fs.lchmodSync === undefined) {
throw new ERR_METHOD_NOT_IMPLEMENTED('lchmodSync');
}
const realPath = this.#resolvePath(vfsPath, false);
fs.lchmodSync(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, listener) {
const realPath = this.#resolvePath(vfsPath);
return fs.watchFile(realPath, options, listener);
}
unwatchFile(vfsPath, listener) {
const realPath = this.#resolvePath(vfsPath);
fs.unwatchFile(realPath, listener);
}
}
module.exports = {
RealFSProvider,
RealFileHandle,
};