From 2e8663309223718643792b455cf16dee3c248ea1 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 24 May 2026 14:53:37 +0200 Subject: [PATCH] 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 --- lib/fs.js | 571 ++++++++++++++- lib/internal/fs/dir.js | 21 + lib/internal/fs/promises.js | 167 ++++- lib/internal/fs/utils.js | 7 + lib/internal/vfs/errors.js | 12 + lib/internal/vfs/file_system.js | 140 +++- lib/internal/vfs/router.js | 46 ++ lib/internal/vfs/setup.js | 659 ++++++++++++++++++ test/parallel/test-vfs-destructuring.js | 70 ++ test/parallel/test-vfs-fs-accessSync.js | 26 + .../test-vfs-fs-callback-error-paths.js | 83 +++ test/parallel/test-vfs-fs-chmod-callback.js | 39 ++ test/parallel/test-vfs-fs-chmodSync.js | 30 + test/parallel/test-vfs-fs-copyFileSync.js | 27 + test/parallel/test-vfs-fs-createReadStream.js | 59 ++ .../parallel/test-vfs-fs-createWriteStream.js | 47 ++ test/parallel/test-vfs-fs-existsSync.js | 22 + test/parallel/test-vfs-fs-fchmod-callback.js | 40 ++ test/parallel/test-vfs-fs-linkSync.js | 27 + test/parallel/test-vfs-fs-mkdir-callback.js | 70 ++ test/parallel/test-vfs-fs-mkdirSync.js | 31 + test/parallel/test-vfs-fs-mkdtempSync.js | 34 + test/parallel/test-vfs-fs-open-callback.js | 85 +++ test/parallel/test-vfs-fs-openAsBlob.js | 25 + test/parallel/test-vfs-fs-openSync.js | 93 +++ test/parallel/test-vfs-fs-opendir-callback.js | 52 ++ test/parallel/test-vfs-fs-opendirSync.js | 29 + .../test-vfs-fs-promises-buffer-encoding.js | 64 ++ .../test-vfs-fs-promises-stat-no-throw.js | 35 + test/parallel/test-vfs-fs-promises.js | 84 +++ .../parallel/test-vfs-fs-readFile-callback.js | 79 +++ test/parallel/test-vfs-fs-readFileSync.js | 45 ++ test/parallel/test-vfs-fs-readdirSync.js | 47 ++ test/parallel/test-vfs-fs-realpathSync.js | 30 + test/parallel/test-vfs-fs-rename-callback.js | 57 ++ test/parallel/test-vfs-fs-renameSync.js | 29 + test/parallel/test-vfs-fs-rmSync.js | 36 + test/parallel/test-vfs-fs-stat-callback.js | 44 ++ test/parallel/test-vfs-fs-statSync.js | 61 ++ test/parallel/test-vfs-fs-symlink-callback.js | 27 + test/parallel/test-vfs-fs-symlinkSync.js | 30 + .../parallel/test-vfs-fs-truncate-callback.js | 48 ++ test/parallel/test-vfs-fs-truncateSync.js | 24 + test/parallel/test-vfs-fs-watch-dispatch.js | 24 + .../test-vfs-fs-writeFile-callback.js | 43 ++ test/parallel/test-vfs-fs-writeFileSync.js | 29 + test/parallel/test-vfs-mount-errors.js | 159 +++++ test/parallel/test-vfs-mount.js | 174 +++++ test/parallel/test-vfs-multi-mount.js | 65 ++ test/parallel/test-vfs-router.js | 47 ++ 50 files changed, 3758 insertions(+), 35 deletions(-) create mode 100644 lib/internal/vfs/router.js create mode 100644 lib/internal/vfs/setup.js create mode 100644 test/parallel/test-vfs-destructuring.js create mode 100644 test/parallel/test-vfs-fs-accessSync.js create mode 100644 test/parallel/test-vfs-fs-callback-error-paths.js create mode 100644 test/parallel/test-vfs-fs-chmod-callback.js create mode 100644 test/parallel/test-vfs-fs-chmodSync.js create mode 100644 test/parallel/test-vfs-fs-copyFileSync.js create mode 100644 test/parallel/test-vfs-fs-createReadStream.js create mode 100644 test/parallel/test-vfs-fs-createWriteStream.js create mode 100644 test/parallel/test-vfs-fs-existsSync.js create mode 100644 test/parallel/test-vfs-fs-fchmod-callback.js create mode 100644 test/parallel/test-vfs-fs-linkSync.js create mode 100644 test/parallel/test-vfs-fs-mkdir-callback.js create mode 100644 test/parallel/test-vfs-fs-mkdirSync.js create mode 100644 test/parallel/test-vfs-fs-mkdtempSync.js create mode 100644 test/parallel/test-vfs-fs-open-callback.js create mode 100644 test/parallel/test-vfs-fs-openAsBlob.js create mode 100644 test/parallel/test-vfs-fs-openSync.js create mode 100644 test/parallel/test-vfs-fs-opendir-callback.js create mode 100644 test/parallel/test-vfs-fs-opendirSync.js create mode 100644 test/parallel/test-vfs-fs-promises-buffer-encoding.js create mode 100644 test/parallel/test-vfs-fs-promises-stat-no-throw.js create mode 100644 test/parallel/test-vfs-fs-promises.js create mode 100644 test/parallel/test-vfs-fs-readFile-callback.js create mode 100644 test/parallel/test-vfs-fs-readFileSync.js create mode 100644 test/parallel/test-vfs-fs-readdirSync.js create mode 100644 test/parallel/test-vfs-fs-realpathSync.js create mode 100644 test/parallel/test-vfs-fs-rename-callback.js create mode 100644 test/parallel/test-vfs-fs-renameSync.js create mode 100644 test/parallel/test-vfs-fs-rmSync.js create mode 100644 test/parallel/test-vfs-fs-stat-callback.js create mode 100644 test/parallel/test-vfs-fs-statSync.js create mode 100644 test/parallel/test-vfs-fs-symlink-callback.js create mode 100644 test/parallel/test-vfs-fs-symlinkSync.js create mode 100644 test/parallel/test-vfs-fs-truncate-callback.js create mode 100644 test/parallel/test-vfs-fs-truncateSync.js create mode 100644 test/parallel/test-vfs-fs-watch-dispatch.js create mode 100644 test/parallel/test-vfs-fs-writeFile-callback.js create mode 100644 test/parallel/test-vfs-fs-writeFileSync.js create mode 100644 test/parallel/test-vfs-mount-errors.js create mode 100644 test/parallel/test-vfs-mount.js create mode 100644 test/parallel/test-vfs-multi-mount.js create mode 100644 test/parallel/test-vfs-router.js diff --git a/lib/fs.js b/lib/fs.js index d63fad8b2a258b..043e29211a1580 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -127,6 +127,7 @@ const { validateRmOptionsSync, validateRmdirOptions, validateStringAfterArrayBufferView, + vfsState, warnOnNonPortableTemplate, } = require('internal/fs/utils'); const { @@ -195,6 +196,30 @@ function makeStatsCallback(cb) { const isFd = isInt32; +/** + * Route VFS async result (Promise) to error-first callback. + * @param {Promise|undefined} promise The VFS handler result + * @param {Function} callback The error-first callback + * @returns {boolean} true if VFS handled, false otherwise + */ +function vfsResult(promise, callback) { + if (promise === undefined) return false; + PromisePrototypeThen(promise, (result) => callback(null, result), callback); + return true; +} + +/** + * Route VFS async void result to error-first callback. + * @param {Promise|undefined} promise The VFS handler result + * @param {Function} callback The error-first callback + * @returns {boolean} true if VFS handled, false otherwise + */ +function vfsVoid(promise, callback) { + if (promise === undefined) return false; + PromisePrototypeThen(promise, () => callback(null), callback); + return true; +} + function isFileType(stats, fileType) { // Use stats array directly to avoid creating an fs.Stats instance just for // our internal use. @@ -218,6 +243,9 @@ function access(path, mode, callback) { mode = F_OK; } + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.access(path, mode), callback)) return; + path = getValidatedPath(path); callback = makeCallback(callback); @@ -234,6 +262,11 @@ function access(path, mode, callback) { * @returns {void} */ function accessSync(path, mode) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.accessSync(path, mode); + if (result !== undefined) return; + } binding.access(getValidatedPath(path), mode); } @@ -246,6 +279,15 @@ function accessSync(path, mode) { function exists(path, callback) { validateFunction(callback, 'cb'); + const h = vfsState.handlers; + if (h !== null) { + const result = h.existsSync(path); + if (result !== undefined) { + process.nextTick(callback, result); + return; + } + } + function suppressedCallback(err) { callback(!err); } @@ -271,6 +313,11 @@ let showExistsDeprecation = true; * @returns {boolean} */ function existsSync(path) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.existsSync(path); + if (result !== undefined) return result; + } try { path = getValidatedPath(path); } catch (err) { @@ -357,6 +404,14 @@ function checkAborted(signal, callback) { function readFile(path, options, callback) { callback ||= options; validateFunction(callback, 'cb'); + + const h = vfsState.handlers; + if (h !== null) { + const opts = typeof options === 'function' ? undefined : options; + if (checkAborted(opts?.signal, callback)) return; + if (vfsResult(h.readFile(path, opts), callback)) return; + } + options = getOptions(options, { flag: 'r' }); ReadFileContext ??= require('internal/fs/read/context'); const context = new ReadFileContext(callback, options.encoding); @@ -427,6 +482,11 @@ function tryReadSync(fd, isUserFd, buffer, pos, len) { * @returns {string | Buffer} */ function readFileSync(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.readFileSync(path, options); + if (result !== undefined) return result; + } options = getOptions(options, { flag: 'r' }); if (options.encoding === 'utf8' || options.encoding === 'utf-8') { @@ -499,6 +559,9 @@ function close(fd, callback = defaultCloseCallback) { if (callback !== defaultCloseCallback) callback = makeCallback(callback); + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.close(fd), callback)) return; + const req = new FSReqCallback(); req.oncomplete = callback; binding.close(fd, req); @@ -510,6 +573,11 @@ function close(fd, callback = defaultCloseCallback) { * @returns {void} */ function closeSync(fd) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.closeSync(fd); + if (result !== undefined) return; + } binding.close(fd); } @@ -525,7 +593,6 @@ function closeSync(fd) { * @returns {void} */ function open(path, flags, mode, callback) { - path = getValidatedPath(path); if (arguments.length < 3) { callback = flags; flags = 'r'; @@ -536,7 +603,13 @@ function open(path, flags, mode, callback) { } else { mode = parseFileMode(mode, 'mode', 0o666); } + const flagsNumber = stringToFlags(flags); + + const h = vfsState.handlers; + if (h !== null && vfsResult(h.open(path, flagsNumber, mode), callback)) return; + + path = getValidatedPath(path); callback = makeCallback(callback); const req = new FSReqCallback(); @@ -553,10 +626,17 @@ function open(path, flags, mode, callback) { * @returns {number} */ function openSync(path, flags, mode) { + flags = stringToFlags(flags); + mode = parseFileMode(mode, 'mode', 0o666); + const h = vfsState.handlers; + if (h !== null) { + const result = h.openSync(path, flags, mode); + if (result !== undefined) return result; + } return binding.open( getValidatedPath(path), - stringToFlags(flags), - parseFileMode(mode, 'mode', 0o666), + flags, + mode, ); } @@ -571,6 +651,13 @@ function openAsBlob(path, options = kEmptyObject) { validateObject(options, 'options'); const type = options.type || ''; validateString(type, 'options.type'); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.openAsBlob(path, options); + if (result !== undefined) return PromiseResolve(result); + } + // The underlying implementation here returns the Blob synchronously for now. // To give ourselves flexibility to maybe return the Blob asynchronously, // this API returns a Promise. @@ -660,6 +747,16 @@ function read(fd, buffer, offsetOrOptions, length, position, callback) { validateOffsetLengthRead(offset, length, buffer.byteLength); + const h = vfsState.handlers; + if (h !== null) { + const promise = h.read(fd, buffer, offset, length, position); + if (promise !== undefined) { + PromisePrototypeThen(promise, + (bytesRead) => callback(null, bytesRead, buffer), callback); + return; + } + } + function wrapper(err, bytesRead) { // Retain a reference to buffer so that it can't be GC'ed too soon. callback(err, bytesRead || 0, buffer); @@ -729,6 +826,12 @@ function readSync(fd, buffer, offsetOrOptions, length, position) { validateOffsetLengthRead(offset, length, buffer.byteLength); + const h = vfsState.handlers; + if (h !== null) { + const result = h.readSync(fd, buffer, offset, length, position); + if (result !== undefined) return result; + } + return binding.read(fd, buffer, offset, length, position); } @@ -755,12 +858,22 @@ function readv(fd, buffers, position, callback) { callback ||= position; validateFunction(callback, 'cb'); - const req = new FSReqCallback(); - req.oncomplete = wrapper; - if (typeof position !== 'number') position = null; + const h = vfsState.handlers; + if (h !== null) { + const promise = h.readv(fd, buffers, position); + if (promise !== undefined) { + PromisePrototypeThen(promise, + (read) => callback(null, read, buffers), callback); + return; + } + } + + const req = new FSReqCallback(); + req.oncomplete = wrapper; + binding.readBuffers(fd, buffers, position, req); } @@ -782,6 +895,12 @@ function readvSync(fd, buffers, position) { if (typeof position !== 'number') position = null; + const h = vfsState.handlers; + if (h !== null) { + const result = h.readvSync(fd, buffers, position); + if (result !== undefined) return result; + } + return binding.readBuffers(fd, buffers, position); } @@ -830,6 +949,16 @@ function write(fd, buffer, offsetOrOptions, length, position, callback) { position = null; validateOffsetLengthWrite(offset, length, buffer.byteLength); + const h = vfsState.handlers; + if (h !== null) { + const promise = h.write(fd, buffer, offset, length, position); + if (promise !== undefined) { + PromisePrototypeThen(promise, + (bytesWritten) => callback(null, bytesWritten, buffer), callback); + return; + } + } + const req = new FSReqCallback(); req.oncomplete = wrapper; binding.writeBuffer(fd, buffer, offset, length, position, req); @@ -853,6 +982,17 @@ function write(fd, buffer, offsetOrOptions, length, position, callback) { callback = position; validateFunction(callback, 'cb'); + const h = vfsState.handlers; + if (h !== null) { + const bufAsBuffer = Buffer.from(str, length); + const promise = h.write(fd, bufAsBuffer, 0, bufAsBuffer.length, offset); + if (promise !== undefined) { + PromisePrototypeThen(promise, + (bytesWritten) => callback(null, bytesWritten, buffer), callback); + return; + } + } + const req = new FSReqCallback(); req.oncomplete = wrapper; binding.writeString(fd, str, offset, length, req); @@ -898,6 +1038,13 @@ function writeSync(fd, buffer, offsetOrOptions, length, position) { if (typeof length !== 'number') length = buffer.byteLength - offset; validateOffsetLengthWrite(offset, length, buffer.byteLength); + + const h = vfsState.handlers; + if (h !== null) { + const vfsResult = h.writeSync(fd, buffer, offset, length, position); + if (vfsResult !== undefined) return vfsResult; + } + result = binding.writeBuffer(fd, buffer, offset, length, position, undefined, ctx); } else { @@ -906,6 +1053,14 @@ function writeSync(fd, buffer, offsetOrOptions, length, position) { if (offset === undefined) offset = null; + + const h = vfsState.handlers; + if (h !== null) { + const bufAsBuffer = Buffer.from(buffer, length); + const vfsResult = h.writeSync(fd, bufAsBuffer, 0, bufAsBuffer.length, offset); + if (vfsResult !== undefined) return vfsResult; + } + result = binding.writeString(fd, buffer, offset, length, undefined, ctx); } @@ -941,12 +1096,22 @@ function writev(fd, buffers, position, callback) { return; } - const req = new FSReqCallback(); - req.oncomplete = wrapper; - if (typeof position !== 'number') position = null; + const h = vfsState.handlers; + if (h !== null) { + const promise = h.writev(fd, buffers, position); + if (promise !== undefined) { + PromisePrototypeThen(promise, + (written) => callback(null, written, buffers), callback); + return; + } + } + + const req = new FSReqCallback(); + req.oncomplete = wrapper; + binding.writeBuffers(fd, buffers, position, req); } @@ -974,6 +1139,12 @@ function writevSync(fd, buffers, position) { if (typeof position !== 'number') position = null; + const h = vfsState.handlers; + if (h !== null) { + const result = h.writevSync(fd, buffers, position); + if (result !== undefined) return result; + } + return binding.writeBuffers(fd, buffers, position); } @@ -986,6 +1157,9 @@ function writevSync(fd, buffers, position) { * @returns {void} */ function rename(oldPath, newPath, callback) { + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.rename(oldPath, newPath), callback)) return; + callback = makeCallback(callback); const req = new FSReqCallback(); req.oncomplete = callback; @@ -1005,6 +1179,11 @@ function rename(oldPath, newPath, callback) { * @returns {void} */ function renameSync(oldPath, newPath) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.renameSync(oldPath, newPath); + if (result !== undefined) return; + } binding.rename( getValidatedPath(oldPath, 'oldPath'), getValidatedPath(newPath, 'newPath'), @@ -1029,6 +1208,10 @@ function truncate(path, len, callback) { validateInteger(len, 'len'); len = MathMax(0, len); validateFunction(callback, 'cb'); + + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.truncate(path, len), callback)) return; + fs.open(path, 'r+', (er, fd) => { if (er) return callback(er); const req = new FSReqCallback(); @@ -1051,6 +1234,13 @@ function truncateSync(path, len) { if (len === undefined) { len = 0; } + + const h = vfsState.handlers; + if (h !== null) { + const result = h.truncateSync(path, len); + if (result !== undefined) return; + } + // Allow error to be thrown, but still close fd. const fd = fs.openSync(path, 'r+'); try { @@ -1076,6 +1266,9 @@ function ftruncate(fd, len = 0, callback) { len = MathMax(0, len); callback = makeCallback(callback); + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.ftruncate(fd, len), callback)) return; + const req = new FSReqCallback(); req.oncomplete = callback; binding.ftruncate(fd, len, req); @@ -1089,6 +1282,13 @@ function ftruncate(fd, len = 0, callback) { */ function ftruncateSync(fd, len = 0) { validateInteger(len, 'len'); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.ftruncateSync(fd, len < 0 ? 0 : len); + if (result !== undefined) return; + } + binding.ftruncate(fd, len < 0 ? 0 : len); } @@ -1118,6 +1318,9 @@ function rmdir(path, options, callback) { options = undefined; } + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.rmdir(path), callback)) return; + if (options?.recursive !== undefined) { // This API previously accepted a `recursive` option that was deprecated // and removed. However, in order to make the change more visible, we @@ -1146,6 +1349,11 @@ function rmdir(path, options, callback) { * @returns {void} */ function rmdirSync(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.rmdirSync(path); + if (result !== undefined) return; + } path = getValidatedPath(path); if (options?.recursive !== undefined) { @@ -1178,6 +1386,10 @@ function rm(path, options, callback) { callback = options; options = undefined; } + + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.rm(path, options), callback)) return; + path = getValidatedPath(path); validateRmOptions(path, options, false, (err, options) => { @@ -1202,6 +1414,11 @@ function rm(path, options, callback) { * @returns {void} */ function rmSync(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.rmSync(path, options); + if (result !== undefined) return; + } const opts = validateRmOptionsSync(path, options, false); return binding.rmSync(getValidatedPath(path), opts.maxRetries, opts.recursive, opts.retryDelay); } @@ -1215,8 +1432,13 @@ function rmSync(path, options) { * @returns {void} */ function fdatasync(fd, callback) { + callback = makeCallback(callback); + + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.fdatasync(fd), callback)) return; + const req = new FSReqCallback(); - req.oncomplete = makeCallback(callback); + req.oncomplete = callback; if (permission.isEnabled()) { callback(new ERR_ACCESS_DENIED('fdatasync API is disabled when Permission Model is enabled.')); @@ -1233,6 +1455,12 @@ function fdatasync(fd, callback) { * @returns {void} */ function fdatasyncSync(fd) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.fdatasyncSync(fd); + if (result !== undefined) return; + } + if (permission.isEnabled()) { throw new ERR_ACCESS_DENIED('fdatasync API is disabled when Permission Model is enabled.'); } @@ -1247,8 +1475,13 @@ function fdatasyncSync(fd) { * @returns {void} */ function fsync(fd, callback) { + callback = makeCallback(callback); + + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.fsync(fd), callback)) return; + const req = new FSReqCallback(); - req.oncomplete = makeCallback(callback); + req.oncomplete = callback; if (permission.isEnabled()) { callback(new ERR_ACCESS_DENIED('fsync API is disabled when Permission Model is enabled.')); return; @@ -1263,6 +1496,12 @@ function fsync(fd, callback) { * @returns {void} */ function fsyncSync(fd) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.fsyncSync(fd); + if (result !== undefined) return; + } + if (permission.isEnabled()) { throw new ERR_ACCESS_DENIED('fsync API is disabled when Permission Model is enabled.'); } @@ -1280,11 +1519,23 @@ function fsyncSync(fd) { * @returns {void} */ function mkdir(path, options, callback) { - let mode = 0o777; - let recursive = false; if (typeof options === 'function') { callback = options; - } else if (typeof options === 'number' || typeof options === 'string') { + options = undefined; + } + + const h = vfsState.handlers; + if (h !== null) { + const promise = h.mkdir(path, options); + if (promise !== undefined) { + PromisePrototypeThen(promise, (r) => callback(null, r.result), callback); + return; + } + } + + let mode = 0o777; + let recursive = false; + if (typeof options === 'number' || typeof options === 'string') { mode = parseFileMode(options, 'mode'); } else if (options) { if (options.recursive !== undefined) { @@ -1317,6 +1568,11 @@ function mkdir(path, options, callback) { * @returns {string | void} */ function mkdirSync(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const vfsResult = h.mkdirSync(path, options); + if (vfsResult !== undefined) return vfsResult.result; + } let mode = 0o777; let recursive = false; if (typeof options === 'number' || typeof options === 'string') { @@ -1495,7 +1751,15 @@ function readdirSyncRecursive(basePath, options) { * @returns {void} */ function readdir(path, options, callback) { - callback = makeCallback(typeof options === 'function' ? options : callback); + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const h = vfsState.handlers; + if (h !== null && vfsResult(h.readdir(path, options), callback)) return; + + callback = makeCallback(callback); options = getOptions(options); path = getValidatedPath(path); if (options.recursive != null) { @@ -1541,6 +1805,11 @@ function readdir(path, options, callback) { * @returns {string | Buffer[] | Dirent[]} */ function readdirSync(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.readdirSync(path, options); + if (result !== undefined) return result; + } options = getOptions(options); path = getValidatedPath(path); if (options.recursive != null) { @@ -1576,6 +1845,10 @@ function fstat(fd, options = { bigint: false }, callback) { callback = options; options = kEmptyObject; } + + const h = vfsState.handlers; + if (h !== null && vfsResult(h.fstat(fd, options), callback)) return; + callback = makeStatsCallback(callback); const req = new FSReqCallback(options.bigint); @@ -1599,6 +1872,10 @@ function lstat(path, options = { bigint: false }, callback) { callback = options; options = kEmptyObject; } + + const h = vfsState.handlers; + if (h !== null && vfsResult(h.lstat(path, options), callback)) return; + callback = makeStatsCallback(callback); path = getValidatedPath(path); if (permission.isEnabled() && !permission.has('fs.read', path)) { @@ -1632,6 +1909,9 @@ function stat(path, options = { bigint: false, throwIfNoEntry: true }, callback) options = getOptions(options, { bigint: false }); } + const h = vfsState.handlers; + if (h !== null && vfsResult(h.stat(path, options), callback)) return; + callback = makeStatsCallback(callback); path = getValidatedPath(path); @@ -1648,6 +1928,16 @@ function statfs(path, options = { bigint: false }, callback) { options = kEmptyObject; } validateFunction(callback, 'cb'); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.statfsSync(path, options); + if (result !== undefined) { + process.nextTick(callback, null, result); + return; + } + } + path = getValidatedPath(path); const req = new FSReqCallback(options.bigint); req.oncomplete = (err, stats) => { @@ -1668,6 +1958,11 @@ function statfs(path, options = { bigint: false }, callback) { * @returns {Stats | undefined} */ function fstatSync(fd, options = { bigint: false }) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.fstatSync(fd); + if (result !== undefined) return result; + } const stats = binding.fstat(fd, options.bigint, undefined, false); if (stats === undefined) { return; @@ -1686,6 +1981,11 @@ function fstatSync(fd, options = { bigint: false }) { * @returns {Stats | undefined} */ function lstatSync(path, options = { bigint: false, throwIfNoEntry: true }) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.lstatSync(path, options); + if (result !== undefined) return result; + } path = getValidatedPath(path); if (permission.isEnabled() && !permission.has('fs.read', path)) { const resource = BufferIsBuffer(path) ? BufferToString(path) : path; @@ -1715,6 +2015,11 @@ function lstatSync(path, options = { bigint: false, throwIfNoEntry: true }) { * @returns {Stats} */ function statSync(path, options = { bigint: false, throwIfNoEntry: true }) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.statSync(path, options); + if (result !== undefined) return result; + } const stats = binding.stat( getValidatedPath(path), options.bigint, @@ -1728,6 +2033,12 @@ function statSync(path, options = { bigint: false, throwIfNoEntry: true }) { } function statfsSync(path, options = { bigint: false }) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.statfsSync(path, options); + if (result !== undefined) return result; + } + const stats = binding.statfs(getValidatedPath(path), options.bigint); return getStatFsFromBinding(stats); } @@ -1744,7 +2055,15 @@ function statfsSync(path, options = { bigint: false }) { * @returns {void} */ function readlink(path, options, callback) { - callback = makeCallback(typeof options === 'function' ? options : callback); + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const h = vfsState.handlers; + if (h !== null && vfsResult(h.readlink(path, options), callback)) return; + + callback = makeCallback(callback); options = getOptions(options); const req = new FSReqCallback(); req.oncomplete = callback; @@ -1759,6 +2078,11 @@ function readlink(path, options, callback) { * @returns {string | Buffer} */ function readlinkSync(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.readlinkSync(path, options); + if (result !== undefined) return result; + } options = getOptions(options); return binding.readlink(getValidatedPath(path), options.encoding); } @@ -1779,6 +2103,9 @@ function symlink(target, path, type, callback) { validateOneOf(type, 'type', ['dir', 'file', 'junction', null, undefined]); } + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.symlink(target, path, type), callback)) return; + // Due to the nature of Node.js runtime, symlinks has different edge cases that can bypass // the permission model security guarantees. Thus, this API is disabled unless fs.read // and fs.write permission has been given. @@ -1840,6 +2167,11 @@ function symlink(target, path, type, callback) { * @returns {void} */ function symlinkSync(target, path, type) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.symlinkSync(target, path, type); + if (result !== undefined) return; + } validateOneOf(type, 'type', ['dir', 'file', 'junction', null, undefined]); if (isWindows && type == null) { const absoluteTarget = pathModule.resolve(`${path}`, '..', `${target}`); @@ -1876,6 +2208,9 @@ function symlinkSync(target, path, type) { function link(existingPath, newPath, callback) { callback = makeCallback(callback); + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.link(existingPath, newPath), callback)) return; + existingPath = getValidatedPath(existingPath, 'existingPath'); newPath = getValidatedPath(newPath, 'newPath'); @@ -1893,6 +2228,12 @@ function link(existingPath, newPath, callback) { * @returns {void} */ function linkSync(existingPath, newPath) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.linkSync(existingPath, newPath); + if (result !== undefined) return; + } + existingPath = getValidatedPath(existingPath, 'existingPath'); newPath = getValidatedPath(newPath, 'newPath'); @@ -1909,6 +2250,9 @@ function linkSync(existingPath, newPath) { * @returns {void} */ function unlink(path, callback) { + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.unlink(path), callback)) return; + callback = makeCallback(callback); const req = new FSReqCallback(); req.oncomplete = callback; @@ -1921,6 +2265,11 @@ function unlink(path, callback) { * @returns {void} */ function unlinkSync(path) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.unlinkSync(path); + if (result !== undefined) return; + } binding.unlink(getValidatedPath(path)); } @@ -1935,6 +2284,9 @@ function fchmod(fd, mode, callback) { mode = parseFileMode(mode, 'mode'); callback = makeCallback(callback); + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.fchmod(fd), callback)) return; + if (permission.isEnabled()) { callback(new ERR_ACCESS_DENIED('fchmod API is disabled when Permission Model is enabled.')); return; @@ -1952,6 +2304,12 @@ function fchmod(fd, mode, callback) { * @returns {void} */ function fchmodSync(fd, mode) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.fchmodSync(fd); + if (result !== undefined) return; + } + if (permission.isEnabled()) { throw new ERR_ACCESS_DENIED('fchmod API is disabled when Permission Model is enabled.'); } @@ -1971,6 +2329,10 @@ function fchmodSync(fd, mode) { function lchmod(path, mode, callback) { validateFunction(callback, 'cb'); mode = parseFileMode(mode, 'mode'); + + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.lchmod(path, mode), callback)) return; + fs.open(path, O_WRONLY | O_SYMLINK, (err, fd) => { if (err) { callback(err); @@ -2016,6 +2378,9 @@ function chmod(path, mode, callback) { mode = parseFileMode(mode, 'mode'); callback = makeCallback(callback); + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.chmod(path, mode), callback)) return; + const req = new FSReqCallback(); req.oncomplete = callback; binding.chmod(path, mode, req); @@ -2031,6 +2396,12 @@ function chmodSync(path, mode) { path = getValidatedPath(path); mode = parseFileMode(mode, 'mode'); + const h = vfsState.handlers; + if (h !== null) { + const result = h.chmodSync(path, mode); + if (result !== undefined) return; + } + binding.chmod(path, mode); } @@ -2047,6 +2418,10 @@ function lchown(path, uid, gid, callback) { path = getValidatedPath(path); validateInteger(uid, 'uid', -1, kMaxUserId); validateInteger(gid, 'gid', -1, kMaxUserId); + + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.lchown(path, uid, gid), callback)) return; + const req = new FSReqCallback(); req.oncomplete = callback; binding.lchown(path, uid, gid, req); @@ -2063,6 +2438,13 @@ function lchownSync(path, uid, gid) { path = getValidatedPath(path); validateInteger(uid, 'uid', -1, kMaxUserId); validateInteger(gid, 'gid', -1, kMaxUserId); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.lchownSync(path, uid, gid); + if (result !== undefined) return; + } + binding.lchown(path, uid, gid); } @@ -2078,6 +2460,10 @@ function fchown(fd, uid, gid, callback) { validateInteger(uid, 'uid', -1, kMaxUserId); validateInteger(gid, 'gid', -1, kMaxUserId); callback = makeCallback(callback); + + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.fchown(fd), callback)) return; + if (permission.isEnabled()) { callback(new ERR_ACCESS_DENIED('fchown API is disabled when Permission Model is enabled.')); return; @@ -2098,6 +2484,13 @@ function fchown(fd, uid, gid, callback) { function fchownSync(fd, uid, gid) { validateInteger(uid, 'uid', -1, kMaxUserId); validateInteger(gid, 'gid', -1, kMaxUserId); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.fchownSync(fd); + if (result !== undefined) return; + } + if (permission.isEnabled()) { throw new ERR_ACCESS_DENIED('fchown API is disabled when Permission Model is enabled.'); } @@ -2120,6 +2513,9 @@ function chown(path, uid, gid, callback) { validateInteger(uid, 'uid', -1, kMaxUserId); validateInteger(gid, 'gid', -1, kMaxUserId); + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.chown(path, uid, gid), callback)) return; + const req = new FSReqCallback(); req.oncomplete = callback; binding.chown(path, uid, gid, req); @@ -2137,6 +2533,13 @@ function chownSync(path, uid, gid) { path = getValidatedPath(path); validateInteger(uid, 'uid', -1, kMaxUserId); validateInteger(gid, 'gid', -1, kMaxUserId); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.chownSync(path, uid, gid); + if (result !== undefined) return; + } + binding.chown(path, uid, gid); } @@ -2153,6 +2556,9 @@ function utimes(path, atime, mtime, callback) { callback = makeCallback(callback); path = getValidatedPath(path); + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.utimes(path, atime, mtime), callback)) return; + const req = new FSReqCallback(); req.oncomplete = callback; binding.utimes( @@ -2172,8 +2578,16 @@ function utimes(path, atime, mtime, callback) { * @returns {void} */ function utimesSync(path, atime, mtime) { + path = getValidatedPath(path); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.utimesSync(path, atime, mtime); + if (result !== undefined) return; + } + binding.utimes( - getValidatedPath(path), + path, toUnixTimestamp(atime), toUnixTimestamp(mtime), ); @@ -2193,6 +2607,9 @@ function futimes(fd, atime, mtime, callback) { mtime = toUnixTimestamp(mtime, 'mtime'); callback = makeCallback(callback); + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.futimes(fd), callback)) return; + if (permission.isEnabled()) { callback(new ERR_ACCESS_DENIED('futimes API is disabled when Permission Model is enabled.')); return; @@ -2213,6 +2630,12 @@ function futimes(fd, atime, mtime, callback) { * @returns {void} */ function futimesSync(fd, atime, mtime) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.futimesSync(fd); + if (result !== undefined) return; + } + if (permission.isEnabled()) { throw new ERR_ACCESS_DENIED('futimes API is disabled when Permission Model is enabled.'); } @@ -2237,6 +2660,9 @@ function lutimes(path, atime, mtime, callback) { callback = makeCallback(callback); path = getValidatedPath(path); + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.lutimes(path, atime, mtime), callback)) return; + const req = new FSReqCallback(); req.oncomplete = callback; binding.lutimes( @@ -2256,8 +2682,16 @@ function lutimes(path, atime, mtime, callback) { * @returns {void} */ function lutimesSync(path, atime, mtime) { + path = getValidatedPath(path); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.lutimesSync(path, atime, mtime); + if (result !== undefined) return; + } + binding.lutimes( - getValidatedPath(path), + path, toUnixTimestamp(atime), toUnixTimestamp(mtime), ); @@ -2334,16 +2768,24 @@ function writeAll(fd, isUserFd, buffer, offset, length, signal, flush, callback) function writeFile(path, data, options, callback) { callback ||= options; validateFunction(callback, 'cb'); - options = getOptions(options, { + + options = getOptions(typeof options === 'function' ? null : options, { encoding: 'utf8', mode: 0o666, flag: 'w', flush: false, }); - const flag = options.flag || 'w'; const flush = options.flush ?? false; - validateBoolean(flush, 'options.flush'); + parseFileMode(options.mode, 'mode', 0o666); + + const h = vfsState.handlers; + if (h !== null) { + if (checkAborted(options.signal, callback)) return; + if (vfsVoid(h.writeFile(path, data, options), callback)) return; + } + + const flag = options.flag || 'w'; if (!isArrayBufferView(data)) { validateStringAfterArrayBufferView(data, 'data'); @@ -2390,10 +2832,15 @@ function writeFileSync(path, data, options) { flag: 'w', flush: false, }); - const flush = options.flush ?? false; - validateBoolean(flush, 'options.flush'); + parseFileMode(options.mode, 'mode', 0o666); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.writeFileSync(path, data, options); + if (result !== undefined) return; + } const flag = options.flag || 'w'; @@ -2452,7 +2899,17 @@ function writeFileSync(path, data, options) { function appendFile(path, data, options, callback) { callback ||= options; validateFunction(callback, 'cb'); - options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'a' }); + + options = getOptions(typeof options === 'function' ? null : options, { + encoding: 'utf8', mode: 0o666, flag: 'a', + }); + parseFileMode(options.mode, 'mode', 0o666); + + const h = vfsState.handlers; + if (h !== null) { + if (checkAborted(options.signal, callback)) return; + if (vfsVoid(h.appendFile(path, data, options), callback)) return; + } // Don't make changes directly on options object options = copyObject(options); @@ -2477,6 +2934,13 @@ function appendFile(path, data, options, callback) { */ function appendFileSync(path, data, options) { options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'a' }); + parseFileMode(options.mode, 'mode', 0o666); + + const h = vfsState.handlers; + if (h !== null) { + const result = h.appendFileSync(path, data, options); + if (result !== undefined) return; + } // Don't make changes directly on options object options = copyObject(options); @@ -2505,6 +2969,11 @@ function appendFileSync(path, data, options) { * @returns {watchers.FSWatcher} */ function watch(filename, options, listener) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.watch(filename, options, listener); + if (result !== undefined) return result; + } if (typeof options === 'function') { listener = options; } @@ -2574,6 +3043,11 @@ const statWatchers = new SafeMap(); * @returns {watchers.StatWatcher} */ function watchFile(filename, options, listener) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.watchFile(filename, options, listener); + if (result !== undefined) return result; + } filename = getValidatedPath(filename); filename = pathModule.resolve(filename); let stat; @@ -2616,6 +3090,10 @@ function watchFile(filename, options, listener) { * @returns {void} */ function unwatchFile(filename, listener) { + const h = vfsState.handlers; + if (h !== null) { + if (h.unwatchFile(filename, listener)) return; + } filename = getValidatedPath(filename); filename = pathModule.resolve(filename); const stat = statWatchers.get(filename); @@ -2693,6 +3171,11 @@ if (isWindows) { * @returns {string | Buffer} */ function realpathSync(p, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.realpathSync(p, options); + if (result !== undefined) return result; + } options = getOptions(options); p = toPathIfFileURL(p); if (typeof p !== 'string') { @@ -2829,6 +3312,11 @@ function realpathSync(p, options) { * @returns {string | Buffer} */ realpathSync.native = (path, options) => { + const h = vfsState.handlers; + if (h !== null) { + const result = h.realpathSync(path, options); + if (result !== undefined) return result; + } options = getOptions(options); return binding.realpath( getValidatedPath(path), @@ -2850,9 +3338,14 @@ realpathSync.native = (path, options) => { function realpath(p, options, callback) { if (typeof options === 'function') { callback = options; + options = undefined; } else { validateFunction(callback, 'cb'); } + + const h = vfsState.handlers; + if (h !== null && vfsResult(h.realpath(p, options), callback)) return; + options = getOptions(options); p = toPathIfFileURL(p); @@ -2991,6 +3484,8 @@ function realpath(p, options, callback) { */ realpath.native = (path, options, callback) => { callback = makeCallback(callback || options); + const h = vfsState.handlers; + if (h !== null && vfsResult(h.realpath(path, options), callback)) return; options = getOptions(options); path = getValidatedPath(path); const req = new FSReqCallback(); @@ -3010,6 +3505,10 @@ realpath.native = (path, options, callback) => { */ function mkdtemp(prefix, options, callback) { callback = makeCallback(typeof options === 'function' ? options : callback); + + const h = vfsState.handlers; + if (h !== null && vfsResult(h.mkdtemp(prefix, typeof options === 'function' ? undefined : options), callback)) return; + options = getOptions(options); prefix = getValidatedPath(prefix, 'prefix'); @@ -3027,6 +3526,12 @@ function mkdtemp(prefix, options, callback) { * @returns {string} */ function mkdtempSync(prefix, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.mkdtempSync(prefix, options); + if (result !== undefined) return result; + } + options = getOptions(options); prefix = getValidatedPath(prefix, 'prefix'); @@ -3079,6 +3584,9 @@ function copyFile(src, dest, mode, callback) { mode = 0; } + const h = vfsState.handlers; + if (h !== null && vfsVoid(h.copyFile(src, dest, mode), callback)) return; + src = getValidatedPath(src, 'src'); dest = getValidatedPath(dest, 'dest'); callback = makeCallback(callback); @@ -3097,6 +3605,11 @@ function copyFile(src, dest, mode, callback) { * @returns {void} */ function copyFileSync(src, dest, mode) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.copyFileSync(src, dest, mode); + if (result !== undefined) return; + } binding.copyFile( getValidatedPath(src, 'src'), getValidatedPath(dest, 'dest'), @@ -3170,6 +3683,11 @@ function lazyLoadStreams() { * @returns {ReadStream} */ function createReadStream(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.createReadStream(path, options); + if (result !== undefined) return result; + } lazyLoadStreams(); return new ReadStream(path, options); } @@ -3193,6 +3711,11 @@ function createReadStream(path, options) { * @returns {WriteStream} */ function createWriteStream(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.createWriteStream(path, options); + if (result !== undefined) return result; + } lazyLoadStreams(); return new WriteStream(path, options); } diff --git a/lib/internal/fs/dir.js b/lib/internal/fs/dir.js index 03f585bab2afaf..32050f31ae6d5d 100644 --- a/lib/internal/fs/dir.js +++ b/lib/internal/fs/dir.js @@ -31,6 +31,7 @@ const { getDirent, getOptions, getValidatedPath, + vfsState, } = require('internal/fs/utils'); const { validateFunction, @@ -330,6 +331,20 @@ function opendir(path, options, callback) { callback = typeof options === 'function' ? options : callback; validateFunction(callback, 'callback'); + const h = vfsState.handlers; + if (h !== null) { + try { + const result = h.opendirSync(path, options); + if (result !== undefined) { + process.nextTick(callback, null, result); + return; + } + } catch (err) { + process.nextTick(callback, err); + return; + } + } + path = getValidatedPath(path); options = getOptions(options, { encoding: 'utf8', @@ -354,6 +369,12 @@ function opendir(path, options, callback) { } function opendirSync(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.opendirSync(path, options); + if (result !== undefined) return result; + } + path = getValidatedPath(path); options = getOptions(options, { encoding: 'utf8' }); diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index 0aa01d9b39dc3e..5c68a37574754f 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -79,6 +79,7 @@ const { validateRmOptions, validateRmdirOptions, validateStringAfterArrayBufferView, + vfsState, warnOnNonPortableTemplate, } = require('internal/fs/utils'); const { opendir } = require('internal/fs/dir'); @@ -1251,6 +1252,11 @@ async function readFileHandle(filehandle, options) { // All of the functions are defined as async in order to ensure that errors // thrown cause promise rejections rather than being thrown synchronously. async function access(path, mode = F_OK) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.access(path, mode); + if (promise !== undefined) { await promise; return; } + } return await PromisePrototypeThen( binding.access(getValidatedPath(path), mode, kUsePromises), undefined, @@ -1266,6 +1272,11 @@ async function cp(src, dest, options) { } async function copyFile(src, dest, mode) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.copyFile(src, dest, mode); + if (promise !== undefined) { await promise; return; } + } return await PromisePrototypeThen( binding.copyFile( getValidatedPath(src, 'src'), @@ -1281,6 +1292,11 @@ async function copyFile(src, dest, mode) { // Note that unlike fs.open() which uses numeric file descriptors, // fsPromises.open() uses the fs.FileHandle class. async function open(path, flags, mode) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.promisesOpen(path, flags, mode); + if (result !== undefined) return result; + } path = getValidatedPath(path); const flagsNumber = stringToFlags(flags); mode = parseFileMode(mode, 'mode', 0o666); @@ -1427,6 +1443,11 @@ async function writev(handle, buffers, position) { } async function rename(oldPath, newPath) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.rename(oldPath, newPath); + if (promise !== undefined) { await promise; return; } + } oldPath = getValidatedPath(oldPath, 'oldPath'); newPath = getValidatedPath(newPath, 'newPath'); return await PromisePrototypeThen( @@ -1437,6 +1458,11 @@ async function rename(oldPath, newPath) { } async function truncate(path, len = 0) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.truncate(path, len); + if (promise !== undefined) { await promise; return; } + } const fd = await open(path, 'r+'); return handleFdClose(ftruncate(fd, len), fd.close); } @@ -1452,12 +1478,22 @@ async function ftruncate(handle, len = 0) { } async function rm(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.rm(path, options); + if (promise !== undefined) { await promise; return; } + } path = getValidatedPath(path); options = await validateRmOptionsPromise(path, options, false); return lazyRimRaf()(path, options); } async function rmdir(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.rmdir(path); + if (promise !== undefined) { await promise; return; } + } path = getValidatedPath(path); if (options?.recursive !== undefined) { @@ -1494,6 +1530,11 @@ async function fsync(handle) { } async function mkdir(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.mkdir(path, options); + if (promise !== undefined) return (await promise).result; + } if (typeof options === 'number' || typeof options === 'string') { options = { mode: options }; } @@ -1592,6 +1633,11 @@ async function readdirRecursive(originalPath, options) { } async function readdir(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.readdir(path, options); + if (promise !== undefined) return await promise; + } options = getOptions(options); // Make shallow copy to prevent mutating options from affecting results @@ -1617,6 +1663,11 @@ async function readdir(path, options) { } async function readlink(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.readlink(path, options); + if (promise !== undefined) return await promise; + } options = getOptions(options); path = getValidatedPath(path, 'oldPath'); return await PromisePrototypeThen( @@ -1627,6 +1678,11 @@ async function readlink(path, options) { } async function symlink(target, path, type) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.symlink(target, path, type); + if (promise !== undefined) { await promise; return; } + } validateOneOf(type, 'type', ['dir', 'file', 'junction', null, undefined]); if (isWindows && type == null) { try { @@ -1669,6 +1725,11 @@ async function fstat(handle, options = { bigint: false }) { } async function lstat(path, options = { bigint: false }) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.lstat(path, options); + if (promise !== undefined) return await promise; + } path = getValidatedPath(path); if (permission.isEnabled() && !permission.has('fs.read', path)) { const resource = pathModule.toNamespacedPath(BufferIsBuffer(path) ? BufferToString(path) : path); @@ -1683,6 +1744,11 @@ async function lstat(path, options = { bigint: false }) { } async function stat(path, options = { bigint: false, throwIfNoEntry: true }) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.stat(path, options); + if (promise !== undefined) return await promise; + } const result = await PromisePrototypeThen( binding.stat(getValidatedPath(path), options.bigint, kUsePromises, options.throwIfNoEntry), undefined, @@ -1696,6 +1762,12 @@ async function stat(path, options = { bigint: false, throwIfNoEntry: true }) { } async function statfs(path, options = { bigint: false }) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.statfs(path, options); + if (result !== undefined) return result; + } + const result = await PromisePrototypeThen( binding.statfs(getValidatedPath(path), options.bigint, kUsePromises), undefined, @@ -1705,6 +1777,11 @@ async function statfs(path, options = { bigint: false }) { } async function link(existingPath, newPath) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.link(existingPath, newPath); + if (promise !== undefined) { await promise; return; } + } existingPath = getValidatedPath(existingPath, 'existingPath'); newPath = getValidatedPath(newPath, 'newPath'); return await PromisePrototypeThen( @@ -1715,6 +1792,11 @@ async function link(existingPath, newPath) { } async function unlink(path) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.unlink(path); + if (promise !== undefined) { await promise; return; } + } return await PromisePrototypeThen( binding.unlink(getValidatedPath(path), kUsePromises), undefined, @@ -1737,6 +1819,13 @@ async function fchmod(handle, mode) { async function chmod(path, mode) { path = getValidatedPath(path); mode = parseFileMode(mode, 'mode'); + + const h = vfsState.handlers; + if (h !== null) { + const promise = h.chmod(path, mode); + if (promise !== undefined) { await promise; return; } + } + return await PromisePrototypeThen( binding.chmod(path, mode, kUsePromises), undefined, @@ -1745,6 +1834,12 @@ async function chmod(path, mode) { } async function lchmod(path, mode) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.lchmod(path, mode); + if (promise !== undefined) { await promise; return; } + } + if (O_SYMLINK === undefined) throw new ERR_METHOD_NOT_IMPLEMENTED('lchmod()'); @@ -1753,6 +1848,12 @@ async function lchmod(path, mode) { } async function lchown(path, uid, gid) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.lchown(path, uid, gid); + if (promise !== undefined) { await promise; return; } + } + path = getValidatedPath(path); validateInteger(uid, 'uid', -1, kMaxUserId); validateInteger(gid, 'gid', -1, kMaxUserId); @@ -1777,6 +1878,12 @@ async function fchown(handle, uid, gid) { } async function chown(path, uid, gid) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.chown(path, uid, gid); + if (promise !== undefined) { await promise; return; } + } + path = getValidatedPath(path); validateInteger(uid, 'uid', -1, kMaxUserId); validateInteger(gid, 'gid', -1, kMaxUserId); @@ -1789,6 +1896,13 @@ async function chown(path, uid, gid) { async function utimes(path, atime, mtime) { path = getValidatedPath(path); + + const h = vfsState.handlers; + if (h !== null) { + const promise = h.utimes(path, atime, mtime); + if (promise !== undefined) { await promise; return; } + } + return await PromisePrototypeThen( binding.utimes( path, @@ -1812,6 +1926,12 @@ async function futimes(handle, atime, mtime) { } async function lutimes(path, atime, mtime) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.lutimes(path, atime, mtime); + if (promise !== undefined) { await promise; return; } + } + return await PromisePrototypeThen( binding.lutimes( getValidatedPath(path), @@ -1825,6 +1945,11 @@ async function lutimes(path, atime, mtime) { } async function realpath(path, options) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.realpath(path, options); + if (promise !== undefined) return await promise; + } options = getOptions(options); return await PromisePrototypeThen( binding.realpath(getValidatedPath(path), options.encoding, kUsePromises), @@ -1834,6 +1959,12 @@ async function realpath(path, options) { } async function mkdtemp(prefix, options) { + const h = vfsState.handlers; + if (h !== null) { + const promise = h.mkdtemp(prefix, options); + if (promise !== undefined) return await promise; + } + options = getOptions(options); prefix = getValidatedPath(prefix, 'prefix'); @@ -1886,10 +2017,18 @@ async function writeFile(path, data, options) { flag: 'w', flush: false, }); - const flag = options.flag || 'w'; const flush = options.flush ?? false; - validateBoolean(flush, 'options.flush'); + parseFileMode(options.mode, 'mode', 0o666); + + const h = vfsState.handlers; + if (h !== null) { + checkAborted(options.signal); + const promise = h.writeFile(path, data, options); + if (promise !== undefined) { await promise; return; } + } + + const flag = options.flag || 'w'; if (!isArrayBufferView(data) && !isCustomIterable(data)) { validateStringAfterArrayBufferView(data, 'data'); @@ -1918,12 +2057,26 @@ function isCustomIterable(obj) { async function appendFile(path, data, options) { options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'a' }); + parseFileMode(options.mode, 'mode', 0o666); + + const h = vfsState.handlers; + if (h !== null) { + checkAborted(options.signal); + const promise = h.appendFile(path, data, options); + if (promise !== undefined) { await promise; return; } + } options = copyObject(options); options.flag ||= 'a'; return writeFile(path, data, options); } async function readFile(path, options) { + const h = vfsState.handlers; + if (h !== null) { + checkAborted(options?.signal); + const result = h.readFile(path, options); + if (result !== undefined) return result; + } options = getOptions(options, { flag: 'r' }); const flag = options.flag || 'r'; @@ -1937,6 +2090,14 @@ async function readFile(path, options) { } async function* _watch(filename, options = kEmptyObject) { + const h = vfsState.handlers; + if (h !== null) { + const result = h.promisesWatch(filename, options); + if (result !== undefined) { + yield* result; + return; + } + } validateObject(options, 'options'); if (options.recursive != null) { @@ -1995,7 +2156,7 @@ module.exports = { writeFile, appendFile, readFile, - watch: !isMacOS && !isWindows ? _watch : watch, + watch: _watch, constants, }, diff --git a/lib/internal/fs/utils.js b/lib/internal/fs/utils.js index 811c52aeffb8b9..f15b63dc20a367 100644 --- a/lib/internal/fs/utils.js +++ b/lib/internal/fs/utils.js @@ -1048,6 +1048,11 @@ const validatePosition = hideStackFrames((position, name, length) => { } }); +// Shared VFS handler state for fs wrapping. +// When handlers is null, no VFS is active (zero overhead). +const vfsState = { __proto__: null, handlers: null }; +function setVfsHandlers(handlers) { vfsState.handlers = handlers; } + module.exports = { constants: { kIoMaxLength, @@ -1057,6 +1062,8 @@ module.exports = { kWriteFileMaxChunkSize, }, assertEncoding, + setVfsHandlers, + vfsState, BigIntStats, // for testing copyObject, Dirent, diff --git a/lib/internal/vfs/errors.js b/lib/internal/vfs/errors.js index 79e4a647d133b1..6af91c4bf7aca4 100644 --- a/lib/internal/vfs/errors.js +++ b/lib/internal/vfs/errors.js @@ -19,6 +19,7 @@ const { UV_EINVAL, UV_ELOOP, UV_EACCES, + UV_EXDEV, } = internalBinding('uv'); /** @@ -179,6 +180,16 @@ function createEACCES(syscall, path) { return err; } +function createEXDEV(syscall, path) { + const err = new UVException({ + errno: UV_EXDEV, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createEXDEV); + return err; +} + module.exports = { createENOENT, createENOTDIR, @@ -190,4 +201,5 @@ module.exports = { createEINVAL, createELOOP, createEACCES, + createEXDEV, }; diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index c48478ee85aa6c..ae38639582581b 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -4,29 +4,58 @@ const { MathRandom, ObjectFreeze, Symbol, + SymbolDispose, } = primordials; +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); const { validateBoolean } = require('internal/validators'); const { MemoryProvider } = require('internal/vfs/providers/memory'); -const { posix: pathPosix } = require('path'); +const path = require('path'); +const { posix: pathPosix, isAbsolute, resolve: resolvePath } = path; const { join: joinPath } = pathPosix; +const { + isUnderMountPoint, + getRelativePath, +} = require('internal/vfs/router'); const { openVirtualFd, getVirtualFd, closeVirtualFd, } = require('internal/vfs/fd'); const { + createENOENT, createEBADF, createEISDIR, } = require('internal/vfs/errors'); const { VirtualReadStream, VirtualWriteStream } = require('internal/vfs/streams'); const { VirtualDir } = require('internal/vfs/dir'); const { emitExperimentalWarning, kEmptyObject } = require('internal/util'); +let debug = require('internal/util/debuglog').debuglog('vfs', (fn) => { + debug = fn; +}); // Private symbols const kProvider = Symbol('kProvider'); +const kMountPoint = Symbol('kMountPoint'); +const kMounted = Symbol('kMounted'); const kPromises = Symbol('kPromises'); +// Lazy-loaded VFS setup +let registerVFS; +let deregisterVFS; + +function loadVfsSetup() { + if (!registerVFS) { + const setup = require('internal/vfs/setup'); + registerVFS = setup.registerVFS; + deregisterVFS = setup.deregisterVFS; + } +} + /** * Virtual File System implementation using Provider architecture. * Wraps a Provider and exposes an fs-like API operating on @@ -62,6 +91,8 @@ class VirtualFileSystem { } this[kProvider] = provider ?? new MemoryProvider(); + this[kMountPoint] = null; + this[kMounted] = false; this[kPromises] = null; // Lazy-initialized } @@ -73,6 +104,22 @@ class VirtualFileSystem { return this[kProvider]; } + /** + * Gets the mount point path, or null if not mounted. + * @returns {string|null} + */ + get mountPoint() { + return this[kMountPoint]; + } + + /** + * Returns true if VFS is mounted. + * @returns {boolean} + */ + get mounted() { + return this[kMounted]; + } + /** * Returns true if the provider is read-only. * @returns {boolean} @@ -81,17 +128,91 @@ class VirtualFileSystem { return this[kProvider].readonly; } + // ==================== Mount ==================== + + /** + * Mounts the VFS at a specific path prefix. + * @param {string} prefix The mount point path + * @returns {VirtualFileSystem} The VFS instance for chaining + */ + mount(prefix) { + if (this[kMounted]) { + throw new ERR_INVALID_STATE('VFS is already mounted'); + } + this[kMountPoint] = resolvePath(prefix); + this[kMounted] = true; + debug('mount %s', this[kMountPoint]); + loadVfsSetup(); + registerVFS(this); + return this; + } + + /** + * Unmounts the VFS. + */ + unmount() { + debug('unmount %s', this[kMountPoint]); + loadVfsSetup(); + deregisterVFS(this); + this[kMountPoint] = null; + this[kMounted] = false; + } + + /** + * Disposes of the VFS by unmounting it. + * Supports the Explicit Resource Management proposal (using declaration). + */ + [SymbolDispose]() { + if (this[kMounted]) { + this.unmount(); + } + } + + /** + * Checks if a path should be handled by this VFS. + * @param {string} inputPath The path to check (must be absolute & normalized) + * @returns {boolean} + */ + shouldHandle(inputPath) { + if (!this[kMounted] || !this[kMountPoint]) { + return false; + } + const normalized = isAbsolute(inputPath) ? inputPath : resolvePath(inputPath); + return isUnderMountPoint(normalized, this[kMountPoint]); + } + // ==================== Path Resolution ==================== /** - * Normalizes a path to a provider-relative POSIX path. - * @param {string} inputPath The path to normalize + * Converts an absolute mounted path to a provider-relative POSIX path. + * If not mounted, treats the path as already provider-relative. + * @param {string} inputPath The path to convert * @returns {string} */ #toProviderPath(inputPath) { + if (this[kMounted] && this[kMountPoint]) { + const resolved = isAbsolute(inputPath) ? inputPath : resolvePath(inputPath); + if (!isUnderMountPoint(resolved, this[kMountPoint])) { + throw createENOENT('open', inputPath); + } + return getRelativePath(resolved, this[kMountPoint]); + } return pathPosix.normalize(inputPath); } + /** + * Converts a provider-relative path back to a mounted path. + * If not mounted, returns the path as-is. + * @param {string} providerPath The provider-relative path + * @returns {string} The mounted path + */ + #toMountedPath(providerPath) { + if (this[kMounted] && this[kMountPoint]) { + return path.join(this[kMountPoint], providerPath); + } + return providerPath; + } + // ==================== FS Operations (Sync) ==================== /** @@ -258,7 +379,8 @@ class VirtualFileSystem { */ realpathSync(filePath, options) { const providerPath = this.#toProviderPath(filePath); - return this[kProvider].realpathSync(providerPath, options); + const realProviderPath = this[kProvider].realpathSync(providerPath, options); + return this.#toMountedPath(realProviderPath); } /** @@ -409,7 +531,7 @@ class VirtualFileSystem { } const dirPath = providerPrefix + suffix; this[kProvider].mkdirSync(dirPath); - return dirPath; + return this.#toMountedPath(dirPath); } /** @@ -612,7 +734,8 @@ class VirtualFileSystem { } this[kProvider].realpath(this.#toProviderPath(filePath), options) - .then((realPath) => callback(null, realPath), (err) => callback(err)); + .then((realPath) => callback(null, this.#toMountedPath(realPath)), + (err) => callback(err)); } /** @@ -959,6 +1082,7 @@ class VirtualFileSystem { // Use arrow function to capture `this` for private method access const toProviderPath = (p) => this.#toProviderPath(p); + const toMountedPath = (p) => this.#toMountedPath(p); return ObjectFreeze({ async readFile(filePath, options) { @@ -1020,7 +1144,7 @@ class VirtualFileSystem { async realpath(filePath, options) { const providerPath = toProviderPath(filePath); - return provider.realpath(providerPath, options); + return toMountedPath(await provider.realpath(providerPath, options)); }, async readlink(linkPath, options) { @@ -1095,7 +1219,7 @@ class VirtualFileSystem { } const dirPath = providerPrefix + suffix; await provider.mkdir(dirPath); - return dirPath; + return toMountedPath(dirPath); }, async chmod(filePath, mode) { diff --git a/lib/internal/vfs/router.js b/lib/internal/vfs/router.js new file mode 100644 index 00000000000000..b610b271695a3d --- /dev/null +++ b/lib/internal/vfs/router.js @@ -0,0 +1,46 @@ +'use strict'; + +const { + ArrayPrototypeJoin, + StringPrototypeSplit, + StringPrototypeStartsWith, +} = primordials; + +const { isAbsolute, relative, sep } = require('path'); + +// `path.sep` is required here because on Windows `path.resolve('/virtual')` +// produces 'C:\virtual' and all resolved paths use backslashes - a hardcoded +// '/' check would never match. The trailing-separator guard handles root +// mount points like 'C:\' so we don't end up with 'C:\\'. +function isUnderMountPoint(normalizedPath, mountPoint) { + if (normalizedPath === mountPoint) { + return true; + } + if (mountPoint === '/') { + return StringPrototypeStartsWith(normalizedPath, '/'); + } + const prefix = mountPoint[mountPoint.length - 1] === sep ? + mountPoint : mountPoint + sep; + return StringPrototypeStartsWith(normalizedPath, prefix); +} + +// Returns a POSIX-style relative path the provider can consume. Uses +// `path.relative()` so Windows backslash paths are handled correctly, then +// re-joins with forward slashes for the provider's internal POSIX format. +function getRelativePath(normalizedPath, mountPoint) { + if (normalizedPath === mountPoint) { + return '/'; + } + if (mountPoint === '/') { + return normalizedPath; + } + const rel = relative(mountPoint, normalizedPath); + const segments = StringPrototypeSplit(rel, sep); + return '/' + ArrayPrototypeJoin(segments, '/'); +} + +module.exports = { + isUnderMountPoint, + getRelativePath, + isAbsolutePath: isAbsolute, +}; diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js new file mode 100644 index 00000000000000..52e3e9cc567687 --- /dev/null +++ b/lib/internal/vfs/setup.js @@ -0,0 +1,659 @@ +'use strict'; + +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + ArrayPrototypeSplice, + PromiseResolve, + StringPrototypeStartsWith, +} = primordials; + +const { Buffer } = require('buffer'); +const { resolve } = 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; + const newPrefix = newMount === '/' ? '/' : newMount + '/'; + const existingPrefix = existingMount === '/' ? '/' : existingMount + '/'; + 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, +}; diff --git a/test/parallel/test-vfs-destructuring.js b/test/parallel/test-vfs-destructuring.js new file mode 100644 index 00000000000000..5370570c80d966 --- /dev/null +++ b/test/parallel/test-vfs-destructuring.js @@ -0,0 +1,70 @@ +// Flags: --experimental-vfs +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Destructure fs methods BEFORE mounting any VFS. Because the guards are +// inside each fs method body (not done via monkey-patching), these captured +// references must still route through VFS once a mount is created. +const { + readFileSync, + existsSync, + statSync, + lstatSync, + readdirSync, + realpathSync, +} = require('fs'); + +const myVfs = vfs.create(); +myVfs.mkdirSync('/sub', { recursive: true }); +myVfs.writeFileSync('/file.txt', 'hello from vfs'); +myVfs.writeFileSync('/sub/nested.txt', 'nested content'); +myVfs.mount('/vfs_destr'); + +{ + const content = readFileSync('/vfs_destr/file.txt', 'utf8'); + assert.strictEqual(content, 'hello from vfs'); +} + +{ + assert.strictEqual(existsSync('/vfs_destr/file.txt'), true); + assert.strictEqual(existsSync('/vfs_destr/nonexistent'), false); +} + +{ + const stats = statSync('/vfs_destr/file.txt'); + assert.strictEqual(stats.isFile(), true); + assert.strictEqual(stats.isDirectory(), false); +} + +{ + const stats = lstatSync('/vfs_destr/file.txt'); + assert.strictEqual(stats.isFile(), true); +} + +{ + const entries = readdirSync('/vfs_destr'); + assert.ok(entries.includes('file.txt')); + assert.ok(entries.includes('sub')); +} + +{ + const real = realpathSync('/vfs_destr/file.txt'); + assert.strictEqual(real, '/vfs_destr/file.txt'); +} + +const { readdir, lstat } = require('fs/promises'); + +async function testPromises() { + const entries = await readdir('/vfs_destr'); + assert.ok(entries.includes('file.txt')); + + const stats = await lstat('/vfs_destr/file.txt'); + assert.strictEqual(stats.isFile(), true); +} + +testPromises().then(common.mustCall(() => { + myVfs.unmount(); +})); diff --git a/test/parallel/test-vfs-fs-accessSync.js b/test/parallel/test-vfs-fs-accessSync.js new file mode 100644 index 00000000000000..a05bfd13282306 --- /dev/null +++ b/test/parallel/test-vfs-fs-accessSync.js @@ -0,0 +1,26 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.accessSync dispatches to VFS; missing paths throw ENOENT. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-accessSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.mount(mountPoint); + +// Existing path succeeds +fs.accessSync(path.join(mountPoint, 'src/hello.txt')); +fs.accessSync(path.join(mountPoint, 'src/hello.txt'), fs.constants.F_OK); + +// Missing path throws ENOENT +assert.throws(() => fs.accessSync(path.join(mountPoint, 'missing')), + { code: 'ENOENT' }); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-callback-error-paths.js b/test/parallel/test-vfs-fs-callback-error-paths.js new file mode 100644 index 00000000000000..52ebf6325285d0 --- /dev/null +++ b/test/parallel/test-vfs-fs-callback-error-paths.js @@ -0,0 +1,83 @@ +// Flags: --experimental-vfs +'use strict'; + +// VFS dispatch on the async callback fs methods must surface provider errors +// through the callback, not as a synchronous throw or unhandled rejection. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-cb-err-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// fs.access on missing file inside a mount +{ + const { myVfs, mountPoint } = mounted(); + fs.access(path.join(mountPoint, 'missing'), common.mustCall((err) => { + assert.strictEqual(err?.code, 'ENOENT'); + myVfs.unmount(); + })); +} + +// fs.lstat on missing file inside a mount +{ + const { myVfs, mountPoint } = mounted(); + fs.lstat(path.join(mountPoint, 'missing'), common.mustCall((err) => { + assert.strictEqual(err?.code, 'ENOENT'); + myVfs.unmount(); + })); +} + +// fs.open on a path whose parent directory does not exist +{ + const { myVfs, mountPoint } = mounted(); + fs.open(path.join(mountPoint, 'missing-parent/x.txt'), 'wx', + common.mustCall((err) => { + assert.ok(err); + myVfs.unmount(); + })); +} + +// fs.read on a VFS fd that has been closed -> EBADF through callback +{ + const { myVfs, mountPoint } = mounted(); + const fd = fs.openSync(path.join(mountPoint, 'src/hello.txt'), 'r'); + fs.closeSync(fd); + fs.read(fd, Buffer.alloc(5), 0, 5, 0, common.mustCall((err) => { + assert.strictEqual(err?.code, 'EBADF'); + myVfs.unmount(); + })); +} + +// fs.write on a VFS fd that has been closed -> EBADF through callback +{ + const { myVfs, mountPoint } = mounted(); + const fd = fs.openSync(path.join(mountPoint, 'src/w.txt'), 'w'); + fs.closeSync(fd); + fs.write(fd, Buffer.from('x'), 0, 1, 0, common.mustCall((err) => { + assert.strictEqual(err?.code, 'EBADF'); + myVfs.unmount(); + })); +} + +// fs.fstat on a VFS fd that has been closed -> EBADF through callback +{ + const { myVfs, mountPoint } = mounted(); + const fd = fs.openSync(path.join(mountPoint, 'src/hello.txt'), 'r'); + fs.closeSync(fd); + fs.fstat(fd, common.mustCall((err) => { + assert.strictEqual(err?.code, 'EBADF'); + myVfs.unmount(); + })); +} diff --git a/test/parallel/test-vfs-fs-chmod-callback.js b/test/parallel/test-vfs-fs-chmod-callback.js new file mode 100644 index 00000000000000..3727b4ed2a6fc5 --- /dev/null +++ b/test/parallel/test-vfs-fs-chmod-callback.js @@ -0,0 +1,39 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.chmod, fs.chown, fs.lchown, fs.utimes, and fs.lutimes callbacks dispatch +// through VFS. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-chmod-cb-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.mount(mountPoint); + +const target = path.join(mountPoint, 'src/hello.txt'); +const uid = process.getuid?.() ?? 0; +const gid = process.getgid?.() ?? 0; +const now = new Date(); + +fs.chmod(target, 0o644, common.mustCall((err) => { + assert.strictEqual(err, null); + fs.chown(target, uid, gid, common.mustCall((err2) => { + assert.strictEqual(err2, null); + fs.lchown(target, uid, gid, common.mustCall((err3) => { + assert.strictEqual(err3, null); + fs.utimes(target, now, now, common.mustCall((err4) => { + assert.strictEqual(err4, null); + fs.lutimes(target, now, now, common.mustCall((err5) => { + assert.strictEqual(err5, null); + myVfs.unmount(); + })); + })); + })); + })); +})); diff --git a/test/parallel/test-vfs-fs-chmodSync.js b/test/parallel/test-vfs-fs-chmodSync.js new file mode 100644 index 00000000000000..f4403d3708726c --- /dev/null +++ b/test/parallel/test-vfs-fs-chmodSync.js @@ -0,0 +1,30 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.chmodSync, fs.chownSync, fs.lchownSync, fs.utimesSync, and +// fs.lutimesSync dispatch to VFS. The MemoryProvider accepts these calls as +// metadata mutations without throwing. + +require('../common'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-chmodSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.mount(mountPoint); + +const target = path.join(mountPoint, 'src/hello.txt'); +const uid = process.getuid?.() ?? 0; +const gid = process.getgid?.() ?? 0; +const now = new Date(); + +fs.chmodSync(target, 0o644); +fs.chownSync(target, uid, gid); +fs.lchownSync(target, uid, gid); +fs.utimesSync(target, now, now); +fs.lutimesSync(target, now, now); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-copyFileSync.js b/test/parallel/test-vfs-fs-copyFileSync.js new file mode 100644 index 00000000000000..9f97421329c541 --- /dev/null +++ b/test/parallel/test-vfs-fs-copyFileSync.js @@ -0,0 +1,27 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.copyFileSync dispatches to VFS when both paths are within the same mount. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-copyFileSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello world'); +myVfs.mount(mountPoint); + +fs.copyFileSync( + path.join(mountPoint, 'src/hello.txt'), + path.join(mountPoint, 'src/copy.txt'), +); +assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/copy.txt'), 'utf8'), + 'hello world', +); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-createReadStream.js b/test/parallel/test-vfs-fs-createReadStream.js new file mode 100644 index 00000000000000..08303a361e1fb8 --- /dev/null +++ b/test/parallel/test-vfs-fs-createReadStream.js @@ -0,0 +1,59 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.createReadStream dispatches through VFS, including the emitted 'open' +// event with the bitmask-encoded virtual fd. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-createReadStream-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello world'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// Whole-file read +{ + const { myVfs, mountPoint } = mounted(); + const chunks = []; + const stream = fs.createReadStream(path.join(mountPoint, 'src/hello.txt')); + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', common.mustCall(() => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'hello world'); + myVfs.unmount(); + })); +} + +// Slice with start + end (inclusive) +{ + const { myVfs, mountPoint } = mounted(); + const chunks = []; + const stream = fs.createReadStream(path.join(mountPoint, 'src/hello.txt'), + { start: 0, end: 4 }); + assert.strictEqual(stream.path, path.join(mountPoint, 'src/hello.txt')); + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', common.mustCall(() => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'hello'); + myVfs.unmount(); + })); +} + +// 'open' event fires with a VFS fd +{ + const { myVfs, mountPoint } = mounted(); + const stream = fs.createReadStream(path.join(mountPoint, 'src/hello.txt')); + stream.on('open', common.mustCall((fd) => { + assert.notStrictEqual(fd & 0x40000000, 0); + })); + stream.on('end', common.mustCall(() => myVfs.unmount())); + stream.resume(); +} diff --git a/test/parallel/test-vfs-fs-createWriteStream.js b/test/parallel/test-vfs-fs-createWriteStream.js new file mode 100644 index 00000000000000..42660acb7f63b7 --- /dev/null +++ b/test/parallel/test-vfs-fs-createWriteStream.js @@ -0,0 +1,47 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.createWriteStream dispatches through VFS, exposes a `path` property and +// emits an 'open' event with the bitmask-encoded virtual fd. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve( + '/tmp/vfs-createWriteStream-' + process.pid, +); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// Basic write +{ + const { myVfs, mountPoint } = mounted(); + const target = path.join(mountPoint, 'src/sw.txt'); + const stream = fs.createWriteStream(target); + stream.write('stream '); + stream.end('data', common.mustCall(() => { + assert.strictEqual(fs.readFileSync(target, 'utf8'), 'stream data'); + myVfs.unmount(); + })); +} + +// Path getter + 'open' event with a VFS fd +{ + const { myVfs, mountPoint } = mounted(); + const target = path.join(mountPoint, 'src/ws-open.txt'); + const stream = fs.createWriteStream(target); + assert.strictEqual(stream.path, target); + stream.on('open', common.mustCall((fd) => { + assert.notStrictEqual(fd & 0x40000000, 0); + })); + stream.end('done', common.mustCall(() => myVfs.unmount())); +} diff --git a/test/parallel/test-vfs-fs-existsSync.js b/test/parallel/test-vfs-fs-existsSync.js new file mode 100644 index 00000000000000..190304a154ca42 --- /dev/null +++ b/test/parallel/test-vfs-fs-existsSync.js @@ -0,0 +1,22 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.existsSync dispatches to VFS for paths under a mount. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-existsSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.mount(mountPoint); + +assert.strictEqual(fs.existsSync(path.join(mountPoint, 'src/hello.txt')), true); +assert.strictEqual(fs.existsSync(path.join(mountPoint, 'src')), true); +assert.strictEqual(fs.existsSync(path.join(mountPoint, 'missing')), false); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-fchmod-callback.js b/test/parallel/test-vfs-fs-fchmod-callback.js new file mode 100644 index 00000000000000..ada2d84cdc2dbe --- /dev/null +++ b/test/parallel/test-vfs-fs-fchmod-callback.js @@ -0,0 +1,40 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.fchmod, fs.fchown, fs.futimes, fs.fdatasync, and fs.fsync callbacks +// short-circuit through VFS as no-ops on virtual fds. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-fchmod-cb-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.mount(mountPoint); + +const fd = fs.openSync(path.join(mountPoint, 'src/hello.txt'), 'r+'); +const uid = process.getuid?.() ?? 0; +const gid = process.getgid?.() ?? 0; +const now = new Date(); + +fs.fchmod(fd, 0o644, common.mustCall((err) => { + assert.strictEqual(err, null); + fs.fchown(fd, uid, gid, common.mustCall((err2) => { + assert.strictEqual(err2, null); + fs.futimes(fd, now, now, common.mustCall((err3) => { + assert.strictEqual(err3, null); + fs.fdatasync(fd, common.mustCall((err4) => { + assert.strictEqual(err4, null); + fs.fsync(fd, common.mustCall((err5) => { + assert.strictEqual(err5, null); + fs.closeSync(fd); + myVfs.unmount(); + })); + })); + })); + })); +})); diff --git a/test/parallel/test-vfs-fs-linkSync.js b/test/parallel/test-vfs-fs-linkSync.js new file mode 100644 index 00000000000000..f8090b24720d92 --- /dev/null +++ b/test/parallel/test-vfs-fs-linkSync.js @@ -0,0 +1,27 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.linkSync dispatches to VFS for hard links within the same mount. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-linkSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello world'); +myVfs.mount(mountPoint); + +fs.linkSync( + path.join(mountPoint, 'src/hello.txt'), + path.join(mountPoint, 'src/hello-link.txt'), +); +assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/hello-link.txt'), 'utf8'), + 'hello world', +); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-mkdir-callback.js b/test/parallel/test-vfs-fs-mkdir-callback.js new file mode 100644 index 00000000000000..7252a7ed732f83 --- /dev/null +++ b/test/parallel/test-vfs-fs-mkdir-callback.js @@ -0,0 +1,70 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.mkdir, fs.rmdir, fs.rm, and fs.unlink callbacks dispatch through VFS. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-mkdir-cb-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// mkdir (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.mkdir(path.join(mountPoint, 'src/cb-d'), common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual( + fs.statSync(path.join(mountPoint, 'src/cb-d')).isDirectory(), true, + ); + myVfs.unmount(); + })); +} + +// rmdir (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.mkdirSync(path.join(mountPoint, 'src/empty')); + fs.rmdir(path.join(mountPoint, 'src/empty'), common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual(fs.existsSync(path.join(mountPoint, 'src/empty')), + false); + myVfs.unmount(); + })); +} + +// rm (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.rm(path.join(mountPoint, 'src/hello.txt'), + common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual(fs.existsSync(path.join(mountPoint, 'src/hello.txt')), + false); + myVfs.unmount(); + })); +} + +// unlink (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.unlink(path.join(mountPoint, 'src/hello.txt'), + common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual( + fs.existsSync(path.join(mountPoint, 'src/hello.txt')), false, + ); + myVfs.unmount(); + })); +} diff --git a/test/parallel/test-vfs-fs-mkdirSync.js b/test/parallel/test-vfs-fs-mkdirSync.js new file mode 100644 index 00000000000000..de3959a06e3b79 --- /dev/null +++ b/test/parallel/test-vfs-fs-mkdirSync.js @@ -0,0 +1,31 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.mkdirSync dispatches to VFS, including the `recursive: true` form. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-mkdirSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.mount(mountPoint); + +// Plain mkdir +fs.mkdirSync(path.join(mountPoint, 'src/d1')); +assert.strictEqual( + fs.statSync(path.join(mountPoint, 'src/d1')).isDirectory(), true, +); + +// Recursive mkdir creates intermediate directories and returns the first one +const created = fs.mkdirSync(path.join(mountPoint, 'src/a/b/c'), + { recursive: true }); +assert.ok(created !== undefined); +assert.strictEqual( + fs.statSync(path.join(mountPoint, 'src/a/b/c')).isDirectory(), true, +); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-mkdtempSync.js b/test/parallel/test-vfs-fs-mkdtempSync.js new file mode 100644 index 00000000000000..ba837b716cb6ca --- /dev/null +++ b/test/parallel/test-vfs-fs-mkdtempSync.js @@ -0,0 +1,34 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.mkdtempSync dispatches to VFS and returns a mount-rooted path, including +// the buffer-encoding variant. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-mkdtempSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.mount(mountPoint); + +const prefix = path.join(mountPoint, 'src/tmp-'); + +// String result +{ + const dir = fs.mkdtempSync(prefix); + assert.ok(dir.startsWith(prefix)); + assert.strictEqual(dir.length, prefix.length + 6); + assert.strictEqual(fs.statSync(dir).isDirectory(), true); +} + +// Buffer result +{ + const dir = fs.mkdtempSync(prefix, { encoding: 'buffer' }); + assert.ok(Buffer.isBuffer(dir)); +} + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-open-callback.js b/test/parallel/test-vfs-fs-open-callback.js new file mode 100644 index 00000000000000..06ea5d0a89ea68 --- /dev/null +++ b/test/parallel/test-vfs-fs-open-callback.js @@ -0,0 +1,85 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.open / fs.fstat / fs.read / fs.write / fs.close / fs.ftruncate callbacks +// dispatch through VFS. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-open-cb-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello world'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// Open + fstat + read + close +{ + const { myVfs, mountPoint } = mounted(); + fs.open(path.join(mountPoint, 'src/hello.txt'), 'r', + common.mustCall((err, fd) => { + assert.strictEqual(err, null); + assert.notStrictEqual(fd & 0x40000000, 0); + fs.fstat(fd, common.mustCall((err2, stats) => { + assert.strictEqual(err2, null); + assert.strictEqual(stats.isFile(), true); + const buf = Buffer.alloc(11); + fs.read(fd, buf, 0, 11, 0, + common.mustCall((err3, n, buffer) => { + assert.strictEqual(err3, null); + assert.strictEqual(n, 11); + assert.strictEqual(buffer.toString(), 'hello world'); + fs.close(fd, common.mustCall((err4) => { + assert.strictEqual(err4, null); + myVfs.unmount(); + })); + })); + })); + })); +} + +// open + write + close +{ + const { myVfs, mountPoint } = mounted(); + fs.open(path.join(mountPoint, 'src/aw.txt'), 'w', + common.mustCall((err, fd) => { + assert.strictEqual(err, null); + const data = Buffer.from('async-fd'); + fs.write(fd, data, 0, data.length, 0, + common.mustCall((err2, n) => { + assert.strictEqual(err2, null); + assert.strictEqual(n, data.length); + fs.close(fd, common.mustCall(() => { + assert.strictEqual( + fs.readFileSync( + path.join(mountPoint, 'src/aw.txt'), 'utf8'), + 'async-fd', + ); + myVfs.unmount(); + })); + })); + })); +} + +// ftruncate (cb) +{ + const { myVfs, mountPoint } = mounted(); + const fd = fs.openSync(path.join(mountPoint, 'src/hello.txt'), 'r+'); + fs.ftruncate(fd, 5, common.mustCall((err) => { + assert.strictEqual(err, null); + fs.closeSync(fd); + assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/hello.txt'), 'utf8'), + 'hello', + ); + myVfs.unmount(); + })); +} diff --git a/test/parallel/test-vfs-fs-openAsBlob.js b/test/parallel/test-vfs-fs-openAsBlob.js new file mode 100644 index 00000000000000..1c8d175c8f99c4 --- /dev/null +++ b/test/parallel/test-vfs-fs-openAsBlob.js @@ -0,0 +1,25 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.openAsBlob dispatches to VFS and returns a Blob over the virtual file. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-openAsBlob-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello world'); +myVfs.mount(mountPoint); + +fs.openAsBlob(path.join(mountPoint, 'src/hello.txt')) + .then(async (blob) => { + assert.ok(blob instanceof Blob); + assert.strictEqual(blob.size, 11); + assert.strictEqual(await blob.text(), 'hello world'); + myVfs.unmount(); + }) + .then(common.mustCall()); diff --git a/test/parallel/test-vfs-fs-openSync.js b/test/parallel/test-vfs-fs-openSync.js new file mode 100644 index 00000000000000..f2c73f0d634469 --- /dev/null +++ b/test/parallel/test-vfs-fs-openSync.js @@ -0,0 +1,93 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.openSync / fs.readSync / fs.writeSync / fs.fstatSync / fs.closeSync / +// fs.ftruncateSync / fs.readvSync / fs.writevSync dispatch to VFS and operate +// on the bitmask-encoded virtual fd. The noop FD handlers (fchmodSync, etc.) +// short-circuit to true for virtual fds. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-openSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello world'); +myVfs.mount(mountPoint); + +// openSync + fstatSync + readSync + closeSync +{ + const fd = fs.openSync(path.join(mountPoint, 'src/hello.txt'), 'r'); + assert.notStrictEqual(fd & 0x40000000, 0); // VFS bitmask is set + const stats = fs.fstatSync(fd); + assert.strictEqual(stats.isFile(), true); + const buf = Buffer.alloc(11); + assert.strictEqual(fs.readSync(fd, buf, 0, 11, 0), 11); + assert.strictEqual(buf.toString(), 'hello world'); + fs.closeSync(fd); +} + +// openSync + writeSync (buffer) + closeSync +{ + const fd = fs.openSync(path.join(mountPoint, 'src/wfd.txt'), 'w'); + assert.strictEqual(fs.writeSync(fd, Buffer.from('via-fd')), 6); + fs.closeSync(fd); + assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/wfd.txt'), 'utf8'), + 'via-fd', + ); +} + +// writeSync with string + encoding +{ + const fd = fs.openSync(path.join(mountPoint, 'src/str.txt'), 'w'); + const n = fs.writeSync(fd, 'string-data', 0, 'utf8'); + assert.ok(n > 0); + fs.closeSync(fd); + assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/str.txt'), 'utf8'), + 'string-data', + ); +} + +// ftruncateSync +{ + const fd = fs.openSync(path.join(mountPoint, 'src/hello.txt'), 'r+'); + fs.ftruncateSync(fd, 5); + fs.closeSync(fd); + assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/hello.txt'), 'utf8'), + 'hello', + ); +} + +// fchmodSync, fchownSync, futimesSync, fdatasyncSync, fsyncSync are noops +{ + const fd = fs.openSync(path.join(mountPoint, 'src/hello.txt'), 'r+'); + fs.fchmodSync(fd, 0o644); + fs.fchownSync(fd, process.getuid?.() ?? 0, process.getgid?.() ?? 0); + const now = new Date(); + fs.futimesSync(fd, now, now); + fs.fdatasyncSync(fd); + fs.fsyncSync(fd); + fs.closeSync(fd); +} + +// readvSync + writevSync +{ + const wf = fs.openSync(path.join(mountPoint, 'src/v.txt'), 'w'); + fs.writevSync(wf, [Buffer.from('abc'), Buffer.from('def')]); + fs.closeSync(wf); + + const rf = fs.openSync(path.join(mountPoint, 'src/v.txt'), 'r'); + const b1 = Buffer.alloc(3); + const b2 = Buffer.alloc(3); + assert.strictEqual(fs.readvSync(rf, [b1, b2], 0), 6); + assert.strictEqual(b1.toString() + b2.toString(), 'abcdef'); + fs.closeSync(rf); +} + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-opendir-callback.js b/test/parallel/test-vfs-fs-opendir-callback.js new file mode 100644 index 00000000000000..f5911aed5baefb --- /dev/null +++ b/test/parallel/test-vfs-fs-opendir-callback.js @@ -0,0 +1,52 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.opendir callback dispatches through VFS, both via readSync() iteration +// and via async iteration. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-opendir-cb-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello'); + myVfs.writeFileSync('/src/data.json', '{}'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// readSync() iteration +{ + const { myVfs, mountPoint } = mounted(); + fs.opendir(path.join(mountPoint, 'src'), + common.mustCall((err, dir) => { + assert.strictEqual(err, null); + const names = []; + let entry; + while ((entry = dir.readSync()) !== null) names.push(entry.name); + dir.closeSync(); + assert.ok(names.includes('hello.txt')); + myVfs.unmount(); + })); +} + +// for-await-of iteration +{ + const { myVfs, mountPoint } = mounted(); + fs.opendir(path.join(mountPoint, 'src'), + common.mustCall(async (err, dir) => { + assert.strictEqual(err, null); + const names = []; + for await (const entry of dir) names.push(entry.name); + assert.ok(names.includes('hello.txt')); + assert.ok(names.includes('data.json')); + myVfs.unmount(); + })); +} diff --git a/test/parallel/test-vfs-fs-opendirSync.js b/test/parallel/test-vfs-fs-opendirSync.js new file mode 100644 index 00000000000000..18ba4c49dd21eb --- /dev/null +++ b/test/parallel/test-vfs-fs-opendirSync.js @@ -0,0 +1,29 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.opendirSync dispatches to VFS and returns a Dir-like iterable. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-opendirSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src/subdir', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.writeFileSync('/src/data.json', '{}'); +myVfs.mount(mountPoint); + +const dir = fs.opendirSync(path.join(mountPoint, 'src')); +const names = []; +let entry; +while ((entry = dir.readSync()) !== null) names.push(entry.name); +dir.closeSync(); + +assert.ok(names.includes('hello.txt')); +assert.ok(names.includes('data.json')); +assert.ok(names.includes('subdir')); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-promises-buffer-encoding.js b/test/parallel/test-vfs-fs-promises-buffer-encoding.js new file mode 100644 index 00000000000000..cfb38787ab465c --- /dev/null +++ b/test/parallel/test-vfs-fs-promises-buffer-encoding.js @@ -0,0 +1,64 @@ +// Flags: --experimental-vfs +'use strict'; + +// The promise-based fs methods that accept `encoding: 'buffer'` must convert +// the (string) provider result into a Buffer before resolving. + +const common = require('../common'); +const assert = require('assert'); +const fsp = require('fs/promises'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-buf-enc-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +(async () => { + // readdir + { + const { myVfs, mountPoint } = mounted(); + const entries = await fsp.readdir(path.join(mountPoint, 'src'), + { encoding: 'buffer' }); + assert.ok(entries.every(Buffer.isBuffer)); + assert.ok(entries.some((b) => b.toString() === 'hello.txt')); + myVfs.unmount(); + } + + // realpath + { + const { myVfs, mountPoint } = mounted(); + const p = path.join(mountPoint, 'src/hello.txt'); + const rp = await fsp.realpath(p, { encoding: 'buffer' }); + assert.ok(Buffer.isBuffer(rp)); + assert.strictEqual(rp.toString(), p); + myVfs.unmount(); + } + + // readlink + { + const { myVfs, mountPoint } = mounted(); + await fsp.symlink('hello.txt', path.join(mountPoint, 'src/ln.txt')); + const target = await fsp.readlink(path.join(mountPoint, 'src/ln.txt'), + { encoding: 'buffer' }); + assert.ok(Buffer.isBuffer(target)); + assert.strictEqual(target.toString(), 'hello.txt'); + myVfs.unmount(); + } + + // mkdtemp + { + const { myVfs, mountPoint } = mounted(); + const dir = await fsp.mkdtemp(path.join(mountPoint, 'src/td-'), + { encoding: 'buffer' }); + assert.ok(Buffer.isBuffer(dir)); + myVfs.unmount(); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-fs-promises-stat-no-throw.js b/test/parallel/test-vfs-fs-promises-stat-no-throw.js new file mode 100644 index 00000000000000..6bd0c546a02d8c --- /dev/null +++ b/test/parallel/test-vfs-fs-promises-stat-no-throw.js @@ -0,0 +1,35 @@ +// Flags: --experimental-vfs +'use strict'; + +// fsp.stat() with throwIfNoEntry: false on a missing path within a mount +// must resolve with undefined instead of rejecting with ENOENT. + +const common = require('../common'); +const assert = require('assert'); +const fsp = require('fs/promises'); +const path = require('path'); +const vfs = require('node:vfs'); + +(async () => { + const mountPoint = path.resolve('/tmp/vfs-stat-no-throw-' + process.pid); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello'); + myVfs.mount(mountPoint); + + // Missing file -> undefined + const missing = await fsp.stat(path.join(mountPoint, 'src/nope'), + { throwIfNoEntry: false }); + assert.strictEqual(missing, undefined); + + // Existing file -> normal Stats + const stats = await fsp.stat(path.join(mountPoint, 'src/hello.txt'), + { throwIfNoEntry: false }); + assert.strictEqual(stats.isFile(), true); + + // Default behaviour (no option) still rejects on ENOENT + await assert.rejects(fsp.stat(path.join(mountPoint, 'src/nope')), + { code: 'ENOENT' }); + + myVfs.unmount(); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-fs-promises.js b/test/parallel/test-vfs-fs-promises.js new file mode 100644 index 00000000000000..d4b151162f2802 --- /dev/null +++ b/test/parallel/test-vfs-fs-promises.js @@ -0,0 +1,84 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs/promises dispatches through VFS for each supported path-based and +// FileHandle-based operation. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); +const vfs = require('node:vfs'); + +(async () => { + const mountPoint = path.resolve('/tmp/vfs-promises-' + process.pid); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello world'); + myVfs.mount(mountPoint); + const p = (s) => path.join(mountPoint, s); + + // Path-based reads + assert.strictEqual((await fsp.stat(p('src/hello.txt'))).isFile(), true); + assert.strictEqual((await fsp.lstat(p('src/hello.txt'))).isFile(), true); + assert.ok((await fsp.readdir(p('src'))).includes('hello.txt')); + assert.strictEqual(await fsp.readFile(p('src/hello.txt'), 'utf8'), + 'hello world'); + assert.strictEqual(await fsp.realpath(p('src/hello.txt')), + p('src/hello.txt')); + await fsp.access(p('src/hello.txt')); + + // statfs + const sfs = await fsp.statfs(p('src/hello.txt')); + assert.strictEqual(typeof sfs.bsize, 'number'); + + // Path-based writes + await fsp.writeFile(p('src/pw.txt'), 'pdata'); + assert.strictEqual(fs.readFileSync(p('src/pw.txt'), 'utf8'), 'pdata'); + await fsp.appendFile(p('src/pw.txt'), ' more'); + assert.strictEqual(fs.readFileSync(p('src/pw.txt'), 'utf8'), 'pdata more'); + + await fsp.mkdir(p('src/pd')); + await fsp.rmdir(p('src/pd')); + await fsp.rm(p('src/pw.txt')); + assert.strictEqual(fs.existsSync(p('src/pw.txt')), false); + + await fsp.copyFile(p('src/hello.txt'), p('src/pcopy.txt')); + assert.strictEqual(fs.readFileSync(p('src/pcopy.txt'), 'utf8'), + 'hello world'); + + await fsp.rename(p('src/pcopy.txt'), p('src/prenamed.txt')); + assert.strictEqual(fs.existsSync(p('src/pcopy.txt')), false); + await fsp.unlink(p('src/prenamed.txt')); + + await fsp.symlink('hello.txt', p('src/plnk.txt')); + assert.strictEqual(await fsp.readlink(p('src/plnk.txt')), 'hello.txt'); + + await fsp.truncate(p('src/hello.txt'), 5); + assert.strictEqual(fs.readFileSync(p('src/hello.txt'), 'utf8'), 'hello'); + + await fsp.link(p('src/hello.txt'), p('src/plink.txt')); + assert.strictEqual(fs.readFileSync(p('src/plink.txt'), 'utf8'), 'hello'); + + const tmp = await fsp.mkdtemp(p('src/ptmp-')); + assert.ok(tmp.startsWith(p('src/ptmp-'))); + assert.strictEqual(fs.statSync(tmp).isDirectory(), true); + + // Attribute mutations + const uid = process.getuid?.() ?? 0; + const gid = process.getgid?.() ?? 0; + const now = new Date(); + await fsp.chmod(p('src/hello.txt'), 0o644); + await fsp.chown(p('src/hello.txt'), uid, gid); + await fsp.lchown(p('src/hello.txt'), uid, gid); + await fsp.utimes(p('src/hello.txt'), now, now); + await fsp.lutimes(p('src/hello.txt'), now, now); + + // FileHandle via fsp.open + const handle = await fsp.open(p('src/hello.txt'), 'r'); + assert.strictEqual(await handle.readFile('utf8'), 'hello'); + await handle.close(); + + myVfs.unmount(); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-fs-readFile-callback.js b/test/parallel/test-vfs-fs-readFile-callback.js new file mode 100644 index 00000000000000..7da6419bbdc2da --- /dev/null +++ b/test/parallel/test-vfs-fs-readFile-callback.js @@ -0,0 +1,79 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.readFile, fs.readdir, fs.realpath, fs.access, and fs.exists callbacks +// dispatch through VFS. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-readFile-cb-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello world'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// readFile (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.readFile(path.join(mountPoint, 'src/hello.txt'), 'utf8', + common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.strictEqual(data, 'hello world'); + myVfs.unmount(); + })); +} + +// readdir (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.readdir(path.join(mountPoint, 'src'), + common.mustCall((err, entries) => { + assert.strictEqual(err, null); + assert.ok(entries.includes('hello.txt')); + myVfs.unmount(); + })); +} + +// realpath (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.realpath(path.join(mountPoint, 'src/hello.txt'), + common.mustCall((err, rp) => { + assert.strictEqual(err, null); + assert.strictEqual(rp, path.join(mountPoint, 'src/hello.txt')); + myVfs.unmount(); + })); +} + +// access (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.access(path.join(mountPoint, 'src/hello.txt'), + common.mustCall((err) => { + assert.strictEqual(err, null); + myVfs.unmount(); + })); +} + +// exists (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.exists(path.join(mountPoint, 'src/hello.txt'), + common.mustCall((ok) => { + assert.strictEqual(ok, true); + fs.exists(path.join(mountPoint, 'missing'), + common.mustCall((ok2) => { + assert.strictEqual(ok2, false); + myVfs.unmount(); + })); + })); +} diff --git a/test/parallel/test-vfs-fs-readFileSync.js b/test/parallel/test-vfs-fs-readFileSync.js new file mode 100644 index 00000000000000..96e39892e66a9f --- /dev/null +++ b/test/parallel/test-vfs-fs-readFileSync.js @@ -0,0 +1,45 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.readFileSync dispatches to VFS for both string paths and VFS-owned fds. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-readFileSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello world'); +myVfs.mount(mountPoint); + +// Default (buffer) result +{ + const buf = fs.readFileSync(path.join(mountPoint, 'src/hello.txt')); + assert.ok(Buffer.isBuffer(buf)); + assert.strictEqual(buf.toString(), 'hello world'); +} + +// utf8 encoding -> string result +assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/hello.txt'), 'utf8'), + 'hello world', +); + +// Encoding via options object +assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/hello.txt'), + { encoding: 'utf8' }), + 'hello world', +); + +// readFileSync via a VFS fd +{ + const fd = fs.openSync(path.join(mountPoint, 'src/hello.txt'), 'r'); + assert.strictEqual(fs.readFileSync(fd, 'utf8'), 'hello world'); + fs.closeSync(fd); +} + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-readdirSync.js b/test/parallel/test-vfs-fs-readdirSync.js new file mode 100644 index 00000000000000..9a795548133914 --- /dev/null +++ b/test/parallel/test-vfs-fs-readdirSync.js @@ -0,0 +1,47 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.readdirSync dispatches to VFS, including the buffer-encoding and +// withFileTypes options. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-readdirSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src/subdir', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.writeFileSync('/src/data.json', '{}'); +myVfs.mount(mountPoint); + +// Default (utf8 string array) +{ + const entries = fs.readdirSync(path.join(mountPoint, 'src')); + assert.ok(entries.includes('hello.txt')); + assert.ok(entries.includes('data.json')); + assert.ok(entries.includes('subdir')); +} + +// withFileTypes: true -> Dirent array +{ + const dirents = fs.readdirSync(path.join(mountPoint, 'src'), + { withFileTypes: true }); + const hello = dirents.find((d) => d.name === 'hello.txt'); + assert.ok(hello); + assert.strictEqual(hello.isFile(), true); + const subdir = dirents.find((d) => d.name === 'subdir'); + assert.strictEqual(subdir.isDirectory(), true); +} + +// encoding: 'buffer' -> Buffer entries +{ + const entries = fs.readdirSync(path.join(mountPoint, 'src'), + { encoding: 'buffer' }); + assert.ok(entries.every(Buffer.isBuffer)); + assert.ok(entries.some((b) => b.toString() === 'hello.txt')); +} + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-realpathSync.js b/test/parallel/test-vfs-fs-realpathSync.js new file mode 100644 index 00000000000000..77d0288776161d --- /dev/null +++ b/test/parallel/test-vfs-fs-realpathSync.js @@ -0,0 +1,30 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.realpathSync dispatches to VFS and returns a mount-rooted absolute path. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-realpathSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.mount(mountPoint); + +const p = path.join(mountPoint, 'src/hello.txt'); + +// Default string return +assert.strictEqual(fs.realpathSync(p), p); + +// Buffer encoding +{ + const rp = fs.realpathSync(p, { encoding: 'buffer' }); + assert.ok(Buffer.isBuffer(rp)); + assert.strictEqual(rp.toString(), p); +} + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-rename-callback.js b/test/parallel/test-vfs-fs-rename-callback.js new file mode 100644 index 00000000000000..740701be626f20 --- /dev/null +++ b/test/parallel/test-vfs-fs-rename-callback.js @@ -0,0 +1,57 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.rename and fs.copyFile callbacks dispatch through VFS. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-rename-cb-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello world'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// rename (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.rename( + path.join(mountPoint, 'src/hello.txt'), + path.join(mountPoint, 'src/renamed-cb.txt'), + common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual(fs.existsSync(path.join(mountPoint, 'src/hello.txt')), + false); + assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/renamed-cb.txt'), 'utf8'), + 'hello world', + ); + myVfs.unmount(); + }), + ); +} + +// copyFile (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.copyFile( + path.join(mountPoint, 'src/hello.txt'), + path.join(mountPoint, 'src/copy-cb.txt'), + common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/copy-cb.txt'), 'utf8'), + 'hello world', + ); + myVfs.unmount(); + }), + ); +} diff --git a/test/parallel/test-vfs-fs-renameSync.js b/test/parallel/test-vfs-fs-renameSync.js new file mode 100644 index 00000000000000..88c3f17eef5c83 --- /dev/null +++ b/test/parallel/test-vfs-fs-renameSync.js @@ -0,0 +1,29 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.renameSync dispatches to VFS when both paths are within the same mount. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-renameSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello world'); +myVfs.mount(mountPoint); + +fs.renameSync( + path.join(mountPoint, 'src/hello.txt'), + path.join(mountPoint, 'src/renamed.txt'), +); +assert.strictEqual(fs.existsSync(path.join(mountPoint, 'src/hello.txt')), + false); +assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/renamed.txt'), 'utf8'), + 'hello world', +); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-rmSync.js b/test/parallel/test-vfs-fs-rmSync.js new file mode 100644 index 00000000000000..a6b77a66bc41d7 --- /dev/null +++ b/test/parallel/test-vfs-fs-rmSync.js @@ -0,0 +1,36 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.rmSync, fs.rmdirSync, and fs.unlinkSync dispatch to VFS. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-rmSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src/subdir', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.writeFileSync('/src/subdir/inside.txt', 'inside'); +myVfs.mkdirSync('/empty'); +myVfs.mount(mountPoint); + +// rmdirSync on an empty directory +fs.rmdirSync(path.join(mountPoint, 'empty')); +assert.strictEqual(fs.existsSync(path.join(mountPoint, 'empty')), false); + +// unlinkSync on a file +fs.unlinkSync(path.join(mountPoint, 'src/hello.txt')); +assert.strictEqual(fs.existsSync(path.join(mountPoint, 'src/hello.txt')), + false); + +// rmSync with force on a missing path is a no-op +fs.rmSync(path.join(mountPoint, 'missing'), { force: true }); + +// rmSync recursive on a non-empty directory tree +fs.rmSync(path.join(mountPoint, 'src'), { recursive: true }); +assert.strictEqual(fs.existsSync(path.join(mountPoint, 'src')), false); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-stat-callback.js b/test/parallel/test-vfs-fs-stat-callback.js new file mode 100644 index 00000000000000..020787f10e1fd5 --- /dev/null +++ b/test/parallel/test-vfs-fs-stat-callback.js @@ -0,0 +1,44 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.stat and fs.lstat callbacks dispatch through VFS. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-stat-cb-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello world'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// stat (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.stat(path.join(mountPoint, 'src/hello.txt'), + common.mustCall((err, s) => { + assert.strictEqual(err, null); + assert.strictEqual(s.isFile(), true); + assert.strictEqual(s.size, 11); + myVfs.unmount(); + })); +} + +// lstat (cb) +{ + const { myVfs, mountPoint } = mounted(); + fs.lstat(path.join(mountPoint, 'src/hello.txt'), + common.mustCall((err, s) => { + assert.strictEqual(err, null); + assert.strictEqual(s.isFile(), true); + myVfs.unmount(); + })); +} diff --git a/test/parallel/test-vfs-fs-statSync.js b/test/parallel/test-vfs-fs-statSync.js new file mode 100644 index 00000000000000..e952dfab9ad56c --- /dev/null +++ b/test/parallel/test-vfs-fs-statSync.js @@ -0,0 +1,61 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.statSync / fs.lstatSync / fs.statfsSync dispatch through the VFS layer, +// including the `throwIfNoEntry: false` option. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-statSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello world'); +myVfs.mount(mountPoint); + +// statSync on a regular file +{ + const s = fs.statSync(path.join(mountPoint, 'src/hello.txt')); + assert.strictEqual(s.isFile(), true); + assert.strictEqual(s.size, 11); +} + +// statSync with throwIfNoEntry:false on missing path +assert.strictEqual( + fs.statSync(path.join(mountPoint, 'missing'), { throwIfNoEntry: false }), + undefined, +); + +// statSync on missing path throws ENOENT by default +assert.throws(() => fs.statSync(path.join(mountPoint, 'missing')), + { code: 'ENOENT' }); + +// lstatSync on a regular file +{ + const s = fs.lstatSync(path.join(mountPoint, 'src/hello.txt')); + assert.strictEqual(s.isFile(), true); +} + +// lstatSync with throwIfNoEntry:false on missing path +assert.strictEqual( + fs.lstatSync(path.join(mountPoint, 'missing'), { throwIfNoEntry: false }), + undefined, +); + +// statfsSync returns number-typed values by default +{ + const s = fs.statfsSync(path.join(mountPoint, 'src/hello.txt')); + assert.strictEqual(typeof s.bsize, 'number'); +} + +// statfsSync with bigint:true returns BigInt fields +{ + const s = fs.statfsSync(path.join(mountPoint, 'src/hello.txt'), + { bigint: true }); + assert.strictEqual(typeof s.bsize, 'bigint'); +} + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-symlink-callback.js b/test/parallel/test-vfs-fs-symlink-callback.js new file mode 100644 index 00000000000000..481a722f36c149 --- /dev/null +++ b/test/parallel/test-vfs-fs-symlink-callback.js @@ -0,0 +1,27 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.symlink and fs.readlink callbacks dispatch through VFS. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-symlink-cb-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.mount(mountPoint); + +fs.symlink('hello.txt', path.join(mountPoint, 'src/lnk.txt'), + common.mustCall((err) => { + assert.strictEqual(err, null); + fs.readlink(path.join(mountPoint, 'src/lnk.txt'), + common.mustCall((err2, target) => { + assert.strictEqual(err2, null); + assert.strictEqual(target, 'hello.txt'); + myVfs.unmount(); + })); + })); diff --git a/test/parallel/test-vfs-fs-symlinkSync.js b/test/parallel/test-vfs-fs-symlinkSync.js new file mode 100644 index 00000000000000..6d043c59a98ac7 --- /dev/null +++ b/test/parallel/test-vfs-fs-symlinkSync.js @@ -0,0 +1,30 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.symlinkSync and fs.readlinkSync dispatch through VFS, including the +// buffer-encoding variant of readlinkSync. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-symlinkSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.mount(mountPoint); + +fs.symlinkSync('hello.txt', path.join(mountPoint, 'src/link.txt')); +assert.strictEqual( + fs.readlinkSync(path.join(mountPoint, 'src/link.txt')), + 'hello.txt', +); + +const buf = fs.readlinkSync(path.join(mountPoint, 'src/link.txt'), + { encoding: 'buffer' }); +assert.ok(Buffer.isBuffer(buf)); +assert.strictEqual(buf.toString(), 'hello.txt'); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-truncate-callback.js b/test/parallel/test-vfs-fs-truncate-callback.js new file mode 100644 index 00000000000000..c42208f5f99ebd --- /dev/null +++ b/test/parallel/test-vfs-fs-truncate-callback.js @@ -0,0 +1,48 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.truncate, fs.link, and fs.mkdtemp callbacks dispatch through VFS. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-truncate-cb-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello world'); +myVfs.mount(mountPoint); + +fs.truncate(path.join(mountPoint, 'src/hello.txt'), 5, + common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/hello.txt'), + 'utf8'), + 'hello', + ); + + fs.link(path.join(mountPoint, 'src/hello.txt'), + path.join(mountPoint, 'src/lk.txt'), + common.mustCall((err2) => { + assert.strictEqual(err2, null); + assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/lk.txt'), + 'utf8'), + 'hello', + ); + + fs.mkdtemp(path.join(mountPoint, 'src/td-'), + common.mustCall((err3, dir) => { + assert.strictEqual(err3, null); + assert.ok(dir.startsWith( + path.join(mountPoint, 'src/td-'))); + assert.strictEqual( + fs.statSync(dir).isDirectory(), true, + ); + myVfs.unmount(); + })); + })); + })); diff --git a/test/parallel/test-vfs-fs-truncateSync.js b/test/parallel/test-vfs-fs-truncateSync.js new file mode 100644 index 00000000000000..1c15f5647a4716 --- /dev/null +++ b/test/parallel/test-vfs-fs-truncateSync.js @@ -0,0 +1,24 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.truncateSync dispatches to VFS and shrinks the file content. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-truncateSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello world'); +myVfs.mount(mountPoint); + +fs.truncateSync(path.join(mountPoint, 'src/hello.txt'), 5); +assert.strictEqual( + fs.readFileSync(path.join(mountPoint, 'src/hello.txt'), 'utf8'), + 'hello', +); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-watch-dispatch.js b/test/parallel/test-vfs-fs-watch-dispatch.js new file mode 100644 index 00000000000000..6d528d6fef7c62 --- /dev/null +++ b/test/parallel/test-vfs-fs-watch-dispatch.js @@ -0,0 +1,24 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.watch on a path under a mount returns the provider's watcher object +// rather than calling the real-fs watcher. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-watch-dispatch-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.writeFileSync('/src/hello.txt', 'hello'); +myVfs.mount(mountPoint); + +const watcher = fs.watch(path.join(mountPoint, 'src/hello.txt')); +assert.ok(watcher); +assert.strictEqual(typeof watcher.close, 'function'); +watcher.close(); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-fs-writeFile-callback.js b/test/parallel/test-vfs-fs-writeFile-callback.js new file mode 100644 index 00000000000000..f40e20404dcd7f --- /dev/null +++ b/test/parallel/test-vfs-fs-writeFile-callback.js @@ -0,0 +1,43 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.writeFile and fs.appendFile callbacks dispatch through VFS. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseMountPoint = path.resolve('/tmp/vfs-writeFile-cb-' + process.pid); +let counter = 0; +function mounted() { + const mountPoint = baseMountPoint + '-' + (counter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// writeFile (cb) +{ + const { myVfs, mountPoint } = mounted(); + const target = path.join(mountPoint, 'src/cb-w.txt'); + fs.writeFile(target, 'cbw', common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual(fs.readFileSync(target, 'utf8'), 'cbw'); + myVfs.unmount(); + })); +} + +// appendFile (cb) +{ + const { myVfs, mountPoint } = mounted(); + const target = path.join(mountPoint, 'src/cb-a.txt'); + fs.writeFileSync(target, 'base'); + fs.appendFile(target, ' more', common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual(fs.readFileSync(target, 'utf8'), 'base more'); + myVfs.unmount(); + })); +} diff --git a/test/parallel/test-vfs-fs-writeFileSync.js b/test/parallel/test-vfs-fs-writeFileSync.js new file mode 100644 index 00000000000000..7469127bb1fde3 --- /dev/null +++ b/test/parallel/test-vfs-fs-writeFileSync.js @@ -0,0 +1,29 @@ +// Flags: --experimental-vfs +'use strict'; + +// fs.writeFileSync and fs.appendFileSync dispatch to VFS. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-writeFileSync-' + process.pid); +const myVfs = vfs.create(); +myVfs.mkdirSync('/src', { recursive: true }); +myVfs.mount(mountPoint); + +const target = path.join(mountPoint, 'src/new.txt'); + +fs.writeFileSync(target, 'fresh'); +assert.strictEqual(fs.readFileSync(target, 'utf8'), 'fresh'); + +fs.appendFileSync(target, ' more'); +assert.strictEqual(fs.readFileSync(target, 'utf8'), 'fresh more'); + +// Buffer input +fs.writeFileSync(target, Buffer.from('binary')); +assert.strictEqual(fs.readFileSync(target, 'utf8'), 'binary'); + +myVfs.unmount(); diff --git a/test/parallel/test-vfs-mount-errors.js b/test/parallel/test-vfs-mount-errors.js new file mode 100644 index 00000000000000..905f1c6d0683ab --- /dev/null +++ b/test/parallel/test-vfs-mount-errors.js @@ -0,0 +1,159 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +// Error paths in the VFS mount layer: +// - EXDEV when renaming/linking across different VFS instances or VFS<->real +// - lastunmount handler cleanup (vfsState.handlers becomes null again) +// - rename of root mount point is rejected as overlapping + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); +const { vfsState } = require('internal/fs/utils'); + +const baseMountPoint = path.resolve('/tmp/vfs-mount-errors-' + process.pid); +let mountCounter = 0; +const nextMount = () => baseMountPoint + '-' + (mountCounter++); + +// EXDEV: rename across two different VFS instances +{ + const mountA = nextMount(); + const mountB = nextMount(); + const a = vfs.create(); + const b = vfs.create(); + a.writeFileSync('/file.txt', 'a'); + b.mkdirSync('/x', { recursive: true }); + a.mount(mountA); + b.mount(mountB); + + assert.throws( + () => fs.renameSync(path.join(mountA, 'file.txt'), + path.join(mountB, 'x/file.txt')), + { code: 'EXDEV' }, + ); + a.unmount(); + b.unmount(); +} + +// EXDEV: copyFileSync across two different VFS instances +{ + const mountA = nextMount(); + const mountB = nextMount(); + const a = vfs.create(); + const b = vfs.create(); + a.writeFileSync('/file.txt', 'a'); + b.mkdirSync('/x', { recursive: true }); + a.mount(mountA); + b.mount(mountB); + + assert.throws( + () => fs.copyFileSync(path.join(mountA, 'file.txt'), + path.join(mountB, 'x/copy.txt')), + { code: 'EXDEV' }, + ); + a.unmount(); + b.unmount(); +} + +// EXDEV: linkSync across two different VFS instances +{ + const mountA = nextMount(); + const mountB = nextMount(); + const a = vfs.create(); + const b = vfs.create(); + a.writeFileSync('/file.txt', 'a'); + b.mkdirSync('/x', { recursive: true }); + a.mount(mountA); + b.mount(mountB); + + assert.throws( + () => fs.linkSync(path.join(mountA, 'file.txt'), + path.join(mountB, 'x/lk.txt')), + { code: 'EXDEV' }, + ); + a.unmount(); + b.unmount(); +} + +// EXDEV: rename from VFS to a real-fs path +{ + const mountA = nextMount(); + const a = vfs.create(); + a.writeFileSync('/file.txt', 'a'); + a.mount(mountA); + + const tmpReal = '/tmp/vfs-mount-real-' + process.pid + '.txt'; + assert.throws( + () => fs.renameSync(path.join(mountA, 'file.txt'), tmpReal), + { code: 'EXDEV' }, + ); + a.unmount(); +} + +// Handler cleanup: after last unmount, vfsState.handlers returns to null +{ + assert.strictEqual(vfsState.handlers, null); + const x = vfs.create(); + x.mount(nextMount()); + assert.notStrictEqual(vfsState.handlers, null); + x.unmount(); + assert.strictEqual(vfsState.handlers, null); + + // And it re-installs on a subsequent mount + const y = vfs.create(); + y.mount(nextMount()); + assert.notStrictEqual(vfsState.handlers, null); + y.unmount(); + assert.strictEqual(vfsState.handlers, null); +} + +// Two parallel non-overlapping mounts both register, last-out clears handlers +{ + const a = vfs.create(); + const b = vfs.create(); + a.mount(nextMount()); + b.mount(nextMount()); + assert.notStrictEqual(vfsState.handlers, null); + a.unmount(); + assert.notStrictEqual(vfsState.handlers, null); + b.unmount(); + assert.strictEqual(vfsState.handlers, null); +} + +// Overlap detection: nested-under and parent-of both rejected +{ + const parent = nextMount(); + const child = path.join(parent, 'child'); + const a = vfs.create(); + const b = vfs.create(); + a.mount(parent); + assert.throws(() => b.mount(child), { code: 'ERR_INVALID_STATE' }); + a.unmount(); + + // Reverse direction: child first, then parent rejected + const c = vfs.create(); + const d = vfs.create(); + c.mount(child); + assert.throws(() => d.mount(parent), { code: 'ERR_INVALID_STATE' }); + c.unmount(); +} + +// Equal mount points: second one rejected +{ + const m = nextMount(); + const a = vfs.create(); + const b = vfs.create(); + a.mount(m); + assert.throws(() => b.mount(m), { code: 'ERR_INVALID_STATE' }); + a.unmount(); +} + +// Double-mount of same instance rejected +{ + const a = vfs.create(); + a.mount(nextMount()); + assert.throws(() => a.mount(nextMount()), { code: 'ERR_INVALID_STATE' }); + a.unmount(); +} diff --git a/test/parallel/test-vfs-mount.js b/test/parallel/test-vfs-mount.js new file mode 100644 index 00000000000000..1b7c1bd11835ed --- /dev/null +++ b/test/parallel/test-vfs-mount.js @@ -0,0 +1,174 @@ +// Flags: --experimental-vfs +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +// Basic mount/unmount API and dispatch through node:vfs from the public fs. + +const baseMountPoint = path.resolve('/tmp/vfs-mount-' + process.pid); +let mountCounter = 0; + +function createMountedVfs() { + const mountPoint = baseMountPoint + '-' + (mountCounter++); + const myVfs = vfs.create(); + myVfs.mkdirSync('/src', { recursive: true }); + myVfs.writeFileSync('/src/hello.txt', 'hello world'); + myVfs.mount(mountPoint); + return { myVfs, mountPoint }; +} + +// Test: mounted/mountPoint getters +{ + const myVfs = vfs.create(); + assert.strictEqual(myVfs.mounted, false); + assert.strictEqual(myVfs.mountPoint, null); + + const mountPoint = baseMountPoint + '-' + (mountCounter++); + myVfs.mount(mountPoint); + assert.strictEqual(myVfs.mounted, true); + assert.strictEqual(myVfs.mountPoint, mountPoint); + + myVfs.unmount(); + assert.strictEqual(myVfs.mounted, false); + assert.strictEqual(myVfs.mountPoint, null); +} + +// Test: double-mount throws +{ + const myVfs = vfs.create(); + const mountPoint = baseMountPoint + '-' + (mountCounter++); + myVfs.mount(mountPoint); + assert.throws(() => myVfs.mount(mountPoint), { code: 'ERR_INVALID_STATE' }); + myVfs.unmount(); +} + +// Test: overlapping mounts throw +{ + const a = vfs.create(); + const b = vfs.create(); + const mountPoint = baseMountPoint + '-' + (mountCounter++); + a.mount(mountPoint); + assert.throws(() => b.mount(mountPoint), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => b.mount(path.join(mountPoint, 'inner')), + { code: 'ERR_INVALID_STATE' }); + a.unmount(); +} + +// Test: fs.readFileSync intercepted +{ + const { myVfs, mountPoint } = createMountedVfs(); + const content = fs.readFileSync(path.join(mountPoint, 'src/hello.txt'), 'utf8'); + assert.strictEqual(content, 'hello world'); + myVfs.unmount(); +} + +// Test: fs.existsSync intercepted +{ + const { myVfs, mountPoint } = createMountedVfs(); + assert.strictEqual(fs.existsSync(path.join(mountPoint, 'src/hello.txt')), true); + assert.strictEqual(fs.existsSync(path.join(mountPoint, 'nonexistent')), false); + myVfs.unmount(); +} + +// Test: fs.statSync intercepted +{ + const { myVfs, mountPoint } = createMountedVfs(); + const stats = fs.statSync(path.join(mountPoint, 'src/hello.txt')); + assert.strictEqual(stats.isFile(), true); + assert.strictEqual(stats.size, 11); + myVfs.unmount(); +} + +// Test: fs.readdirSync intercepted +{ + const { myVfs, mountPoint } = createMountedVfs(); + const entries = fs.readdirSync(path.join(mountPoint, 'src')); + assert.ok(entries.includes('hello.txt')); + myVfs.unmount(); +} + +// Test: fs.writeFileSync intercepted +{ + const { myVfs, mountPoint } = createMountedVfs(); + const newPath = path.join(mountPoint, 'src/new.txt'); + fs.writeFileSync(newPath, 'fresh'); + assert.strictEqual(fs.readFileSync(newPath, 'utf8'), 'fresh'); + myVfs.unmount(); +} + +// Test: fs callback API +{ + const { myVfs, mountPoint } = createMountedVfs(); + fs.readFile(path.join(mountPoint, 'src/hello.txt'), 'utf8', + common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.strictEqual(data, 'hello world'); + myVfs.unmount(); + })); +} + +// Test: fs.promises API +{ + const { myVfs, mountPoint } = createMountedVfs(); + fs.promises.readFile(path.join(mountPoint, 'src/hello.txt'), 'utf8') + .then(common.mustCall((data) => { + assert.strictEqual(data, 'hello world'); + myVfs.unmount(); + })); +} + +// Test: streams +{ + const { myVfs, mountPoint } = createMountedVfs(); + const chunks = []; + const stream = fs.createReadStream(path.join(mountPoint, 'src/hello.txt')); + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', common.mustCall(() => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'hello world'); + myVfs.unmount(); + })); +} + +// Test: openSync/readSync/closeSync via public fs +{ + const { myVfs, mountPoint } = createMountedVfs(); + const fd = fs.openSync(path.join(mountPoint, 'src/hello.txt'), 'r'); + assert.notStrictEqual(fd & 0x40000000, 0); + const buf = Buffer.alloc(11); + const n = fs.readSync(fd, buf, 0, 11, 0); + assert.strictEqual(n, 11); + assert.strictEqual(buf.toString(), 'hello world'); + fs.closeSync(fd); + myVfs.unmount(); +} + +// Test: ENOENT thrown for missing path under mount +{ + const { myVfs, mountPoint } = createMountedVfs(); + assert.throws(() => fs.readFileSync(path.join(mountPoint, 'src/missing.txt')), + { code: 'ENOENT' }); + myVfs.unmount(); +} + +// Test: paths outside the mount point go to the real fs (no interference) +{ + const { myVfs, mountPoint } = createMountedVfs(); + // /etc/hostname (or any real path) should pass through; assert it doesn't + // hit our VFS by checking that mountPoint is not a prefix of the path. + assert.ok(!path.resolve('/etc').startsWith(mountPoint)); + myVfs.unmount(); +} + +// Test: Symbol.dispose unmounts +{ + const myVfs = vfs.create(); + const mountPoint = baseMountPoint + '-' + (mountCounter++); + myVfs.mount(mountPoint); + assert.strictEqual(myVfs.mounted, true); + myVfs[Symbol.dispose](); + assert.strictEqual(myVfs.mounted, false); +} diff --git a/test/parallel/test-vfs-multi-mount.js b/test/parallel/test-vfs-multi-mount.js new file mode 100644 index 00000000000000..879f7cfcbf2b83 --- /dev/null +++ b/test/parallel/test-vfs-multi-mount.js @@ -0,0 +1,65 @@ +// Flags: --experimental-vfs +'use strict'; + +// Two concurrent non-overlapping mounts must each route to its own VFS without +// interference. Also exercises that the handler registry iterates and routes +// correctly when more than one VFS is active. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +const baseA = path.resolve('/tmp/vfs-multi-a-' + process.pid); +const baseB = path.resolve('/tmp/vfs-multi-b-' + process.pid); + +const a = vfs.create(); +a.writeFileSync('/file.txt', 'from-a'); +a.mkdirSync('/dir', { recursive: true }); +a.writeFileSync('/dir/inside.txt', 'a-inside'); + +const b = vfs.create(); +b.writeFileSync('/file.txt', 'from-b'); +b.mkdirSync('/dir', { recursive: true }); +b.writeFileSync('/dir/inside.txt', 'b-inside'); + +a.mount(baseA); +b.mount(baseB); + +// Each mount sees its own content +assert.strictEqual(fs.readFileSync(path.join(baseA, 'file.txt'), 'utf8'), + 'from-a'); +assert.strictEqual(fs.readFileSync(path.join(baseB, 'file.txt'), 'utf8'), + 'from-b'); + +// Per-mount directory listings are isolated +assert.deepStrictEqual( + fs.readdirSync(baseA).sort(), + ['dir', 'file.txt'], +); +assert.deepStrictEqual( + fs.readdirSync(baseB).sort(), + ['dir', 'file.txt'], +); + +// Writing to one mount doesn't bleed into the other +fs.writeFileSync(path.join(baseA, 'only-a.txt'), 'A'); +assert.strictEqual(fs.existsSync(path.join(baseB, 'only-a.txt')), false); +assert.strictEqual(fs.readFileSync(path.join(baseA, 'only-a.txt'), 'utf8'), + 'A'); + +// realpathSync returns the mount-rooted path (proves #toMountedPath on each) +assert.strictEqual(fs.realpathSync(path.join(baseA, 'file.txt')), + path.join(baseA, 'file.txt')); +assert.strictEqual(fs.realpathSync(path.join(baseB, 'file.txt')), + path.join(baseB, 'file.txt')); + +// Unmount one, the other still works +a.unmount(); +assert.strictEqual(a.mounted, false); +assert.strictEqual(b.mounted, true); +assert.strictEqual(fs.readFileSync(path.join(baseB, 'file.txt'), 'utf8'), + 'from-b'); + +b.unmount(); diff --git a/test/parallel/test-vfs-router.js b/test/parallel/test-vfs-router.js new file mode 100644 index 00000000000000..26922d8082dd7a --- /dev/null +++ b/test/parallel/test-vfs-router.js @@ -0,0 +1,47 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +// Unit-level coverage for the mount-router helpers in +// lib/internal/vfs/router.js. These functions are platform-aware (handle +// Windows backslashes and case differences) but here we focus on the POSIX +// invariants exercised on the test host. + +require('../common'); +const assert = require('assert'); +const { isUnderMountPoint, getRelativePath, isAbsolutePath } = + require('internal/vfs/router'); + +// isUnderMountPoint: equal paths are always considered "under" +assert.strictEqual(isUnderMountPoint('/app', '/app'), true); + +// isUnderMountPoint: nested paths +assert.strictEqual(isUnderMountPoint('/app/src/index.js', '/app'), true); + +// isUnderMountPoint: rejects sibling paths that share the prefix string +assert.strictEqual(isUnderMountPoint('/app2/index.js', '/app'), false); +assert.strictEqual(isUnderMountPoint('/applebrick', '/app'), false); + +// isUnderMountPoint: rejects an unrelated absolute path +assert.strictEqual(isUnderMountPoint('/other', '/app'), false); + +// isUnderMountPoint: root mount matches any absolute path +assert.strictEqual(isUnderMountPoint('/anywhere', '/'), true); +assert.strictEqual(isUnderMountPoint('/', '/'), true); +assert.strictEqual(isUnderMountPoint('/a/b/c', '/'), true); + +// getRelativePath: equal => '/' +assert.strictEqual(getRelativePath('/app', '/app'), '/'); + +// getRelativePath: nested +assert.strictEqual(getRelativePath('/app/src/index.js', '/app'), + '/src/index.js'); + +// getRelativePath: root mount returns the original (already absolute) path +assert.strictEqual(getRelativePath('/foo/bar', '/'), '/foo/bar'); + +// getRelativePath: deeper nesting +assert.strictEqual(getRelativePath('/m/a/b/c/d', '/m/a'), '/b/c/d'); + +// isAbsolutePath is re-exported from node:path +assert.strictEqual(isAbsolutePath('/foo'), true); +assert.strictEqual(isAbsolutePath('foo'), false);