diff --git a/lib/fs.js b/lib/fs.js index b858902cf44a..5a5f13586aa0 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -1864,9 +1864,6 @@ function readdir(path, options, callback) { 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); @@ -1874,6 +1871,9 @@ function readdir(path, options, callback) { validateBoolean(options.recursive, 'options.recursive'); } + const h = vfsState.handlers; + if (h !== null && vfsResult(h.readdir(path, options), callback)) return; + if (options.recursive) { readdirRecursive(path, options, callback); return; @@ -1910,17 +1910,18 @@ 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) { validateBoolean(options.recursive, 'options.recursive'); } + const h = vfsState.handlers; + if (h !== null) { + const result = h.readdirSync(path, options); + if (result !== undefined) return result; + } + if (options.recursive) { return readdirSyncRecursive(path, options); } @@ -2417,7 +2418,7 @@ function fchmod(fd, mode, callback) { callback = makeCallback(callback); const h = vfsState.handlers; - if (h !== null && vfsVoid(h.fchmod(fd), callback)) return; + if (h !== null && vfsVoid(h.fchmod(fd, mode), callback)) return; if (permission.isEnabled()) { callback(new ERR_ACCESS_DENIED('fchmod API is disabled when Permission Model is enabled.')); @@ -2436,19 +2437,18 @@ function fchmod(fd, mode, callback) { * @returns {void} */ function fchmodSync(fd, mode) { + mode = parseFileMode(mode, 'mode'); + const h = vfsState.handlers; if (h !== null) { - const result = h.fchmodSync(fd); + const result = h.fchmodSync(fd, mode); if (result !== undefined) return; } if (permission.isEnabled()) { throw new ERR_ACCESS_DENIED('fchmod API is disabled when Permission Model is enabled.'); } - binding.fchmod( - fd, - parseFileMode(mode, 'mode'), - ); + binding.fchmod(fd, mode); } /** @@ -2687,18 +2687,15 @@ function chownSync(path, uid, gid) { function utimes(path, atime, mtime, callback) { callback = makeCallback(callback); path = getValidatedPath(path); + atime = toUnixTimestamp(atime); + mtime = toUnixTimestamp(mtime); const h = vfsState.handlers; if (h !== null && vfsVoid(h.utimes(path, atime, mtime), callback)) return; const req = new FSReqCallback(); req.oncomplete = callback; - binding.utimes( - path, - toUnixTimestamp(atime), - toUnixTimestamp(mtime), - req, - ); + binding.utimes(path, atime, mtime, req); } /** @@ -2711,6 +2708,8 @@ function utimes(path, atime, mtime, callback) { */ function utimesSync(path, atime, mtime) { path = getValidatedPath(path); + atime = toUnixTimestamp(atime); + mtime = toUnixTimestamp(mtime); const h = vfsState.handlers; if (h !== null) { @@ -2718,11 +2717,7 @@ function utimesSync(path, atime, mtime) { if (result !== undefined) return; } - binding.utimes( - path, - toUnixTimestamp(atime), - toUnixTimestamp(mtime), - ); + binding.utimes(path, atime, mtime); } /** @@ -2740,7 +2735,7 @@ function futimes(fd, atime, mtime, callback) { callback = makeCallback(callback); const h = vfsState.handlers; - if (h !== null && vfsVoid(h.futimes(fd), callback)) return; + if (h !== null && vfsVoid(h.futimes(fd, atime, mtime), callback)) return; if (permission.isEnabled()) { callback(new ERR_ACCESS_DENIED('futimes API is disabled when Permission Model is enabled.')); @@ -2762,9 +2757,12 @@ function futimes(fd, atime, mtime, callback) { * @returns {void} */ function futimesSync(fd, atime, mtime) { + atime = toUnixTimestamp(atime, 'atime'); + mtime = toUnixTimestamp(mtime, 'mtime'); + const h = vfsState.handlers; if (h !== null) { - const result = h.futimesSync(fd); + const result = h.futimesSync(fd, atime, mtime); if (result !== undefined) return; } @@ -2772,11 +2770,7 @@ function futimesSync(fd, atime, mtime) { throw new ERR_ACCESS_DENIED('futimes API is disabled when Permission Model is enabled.'); } - binding.futimes( - fd, - toUnixTimestamp(atime, 'atime'), - toUnixTimestamp(mtime, 'mtime'), - ); + binding.futimes(fd, atime, mtime); } /** @@ -2791,18 +2785,15 @@ function futimesSync(fd, atime, mtime) { function lutimes(path, atime, mtime, callback) { callback = makeCallback(callback); path = getValidatedPath(path); + atime = toUnixTimestamp(atime); + mtime = toUnixTimestamp(mtime); const h = vfsState.handlers; if (h !== null && vfsVoid(h.lutimes(path, atime, mtime), callback)) return; const req = new FSReqCallback(); req.oncomplete = callback; - binding.lutimes( - path, - toUnixTimestamp(atime), - toUnixTimestamp(mtime), - req, - ); + binding.lutimes(path, atime, mtime, req); } /** @@ -2815,6 +2806,8 @@ function lutimes(path, atime, mtime, callback) { */ function lutimesSync(path, atime, mtime) { path = getValidatedPath(path); + atime = toUnixTimestamp(atime); + mtime = toUnixTimestamp(mtime); const h = vfsState.handlers; if (h !== null) { @@ -2822,11 +2815,7 @@ function lutimesSync(path, atime, mtime) { if (result !== undefined) return; } - binding.lutimes( - path, - toUnixTimestamp(atime), - toUnixTimestamp(mtime), - ); + binding.lutimes(path, atime, mtime); } function writeAll(fd, isUserFd, buffer, offset, length, signal, flush, callback) { diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index d5a6b9a2c853..631189b6e6f8 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -1668,17 +1668,18 @@ async function readdirRecursiveWithPermissionModel(basePath, 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 options = copyObject(options); path = getValidatedPath(path); + + const h = vfsState.handlers; + if (h !== null) { + const promise = h.readdir(path, options); + if (promise !== undefined) return await promise; + } if (options.recursive) { return readdirRecursive(path, options); } @@ -1954,6 +1955,8 @@ async function chown(path, uid, gid) { async function utimes(path, atime, mtime) { path = getValidatedPath(path); + atime = toUnixTimestamp(atime); + mtime = toUnixTimestamp(mtime); const h = vfsState.handlers; if (h !== null) { @@ -1962,12 +1965,7 @@ async function utimes(path, atime, mtime) { } return await PromisePrototypeThen( - binding.utimes( - path, - toUnixTimestamp(atime), - toUnixTimestamp(mtime), - kUsePromises, - ), + binding.utimes(path, atime, mtime, kUsePromises), undefined, handleErrorFromBinding, ); @@ -1987,6 +1985,10 @@ async function futimes(handle, atime, mtime) { } async function lutimes(path, atime, mtime) { + path = getValidatedPath(path); + atime = toUnixTimestamp(atime); + mtime = toUnixTimestamp(mtime); + const h = vfsState.handlers; if (h !== null) { const promise = h.lutimes(path, atime, mtime); @@ -1994,12 +1996,7 @@ async function lutimes(path, atime, mtime) { } return await PromisePrototypeThen( - binding.lutimes( - getValidatedPath(path), - toUnixTimestamp(atime), - toUnixTimestamp(mtime), - kUsePromises, - ), + binding.lutimes(path, atime, mtime, kUsePromises), undefined, handleErrorFromBinding, ); diff --git a/lib/internal/vfs/dir.js b/lib/internal/vfs/dir.js index 803aeb404531..3b0a6140b1ee 100644 --- a/lib/internal/vfs/dir.js +++ b/lib/internal/vfs/dir.js @@ -94,10 +94,15 @@ class VirtualDir { this.closeSync(); } } + + async [SymbolAsyncDispose]() { + if (!this.#closed) { + this.closeSync(); + } + } } VirtualDir.prototype[SymbolAsyncIterator] = VirtualDir.prototype.entries; -VirtualDir.prototype[SymbolAsyncDispose] = VirtualDir.prototype.close; module.exports = { VirtualDir, diff --git a/lib/internal/vfs/file_handle.js b/lib/internal/vfs/file_handle.js index 7b60c9def2b5..dd6fa3616da7 100644 --- a/lib/internal/vfs/file_handle.js +++ b/lib/internal/vfs/file_handle.js @@ -20,6 +20,8 @@ const { const { createEBADF, } = require('internal/vfs/errors'); +const { toUnixTimestamp } = require('internal/fs/utils'); +const { parseFileMode } = require('internal/validators'); // Private symbols const kPath = Symbol('kPath'); @@ -241,10 +243,17 @@ class VirtualFileHandle { } /** - * No-op chmod - VFS files don't have real permissions. + * @param {number} mode The new permission bits + */ + chmodSync(mode) {} + + /** + * @param {number} mode The new permission bits * @returns {Promise} */ - async chmod() {} + async chmod(mode) { + this.chmodSync(mode); + } /** * No-op chown - VFS files don't have real ownership. @@ -253,10 +262,19 @@ class VirtualFileHandle { async chown() {} /** - * No-op utimes - timestamps are handled by the provider. + * @param {Date|number|string} atime The new access time + * @param {Date|number|string} mtime The new modification time + */ + utimesSync(atime, mtime) {} + + /** + * @param {Date|number|string} atime The new access time + * @param {Date|number|string} mtime The new modification time * @returns {Promise} */ - async utimes() {} + async utimes(atime, mtime) { + this.utimesSync(atime, mtime); + } /** * No-op datasync - VFS is in-memory. @@ -666,6 +684,33 @@ class MemoryFileHandle extends VirtualFileHandle { throw new ERR_INVALID_STATE('stats not available'); } + /** + * @param {number} mode The new permission bits + */ + chmodSync(mode) { + this.#checkClosed('fchmod'); + mode = parseFileMode(mode, 'mode'); + if (this.#entry) { + this.#entry.mode = (this.#entry.mode & ~0o7777) | (mode & 0o7777); + this.#entry.ctime = DateNow(); + } + } + + /** + * @param {Date|number|string} atime The new access time + * @param {Date|number|string} mtime The new modification time + */ + utimesSync(atime, mtime) { + this.#checkClosed('futimes'); + const atimeMs = toUnixTimestamp(atime, 'atime') * 1000; + const mtimeMs = toUnixTimestamp(mtime, 'mtime') * 1000; + if (this.#entry) { + this.#entry.atime = atimeMs; + this.#entry.mtime = mtimeMs; + this.#entry.ctime = DateNow(); + } + } + /** * Gets file stats. * @param {object} [options] Options diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index 574c076c426d..afb5fab3eb73 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -63,6 +63,17 @@ function normalizeMountedPath(inputPath) { return toNamespacedPath(resolvePath(inputPath)); } +const kTempChars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +function randomSuffix() { + let suffix = ''; + for (let i = 0; i < 6; i++) { + suffix += kTempChars[(MathRandom() * kTempChars.length) | 0]; + } + return suffix; +} + let registerVFS; let deregisterVFS; @@ -359,7 +370,8 @@ class VirtualFileSystem { */ mkdirSync(dirPath, options) { const providerPath = this.#toProviderPath(dirPath); - return this[kProvider].mkdirSync(providerPath, options); + const created = this[kProvider].mkdirSync(providerPath, options); + return created === undefined ? undefined : this.#toMountedPath(created); } /** @@ -557,17 +569,25 @@ class VirtualFileSystem { * @returns {string} The full path of the created directory */ mkdtempSync(prefix) { - const providerPrefix = this.#toProviderPath(prefix); - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - let suffix = ''; - for (let i = 0; i < 6; i++) { - suffix += chars[(MathRandom() * chars.length) | 0]; - } - const dirPath = providerPrefix + suffix; + const dirPath = this.#toProviderPrefix(prefix) + randomSuffix(); this[kProvider].mkdirSync(dirPath); return this.#toMountedPath(dirPath); } + /** + * Converts a mkdtemp prefix to a provider-relative one, keeping a + * trailing separator. + * @param {string} prefix The mounted prefix + * @returns {string} + */ + #toProviderPrefix(prefix) { + const last = prefix[prefix.length - 1]; + const trailing = last === '/' || last === sep; + const providerPrefix = this.#toProviderPath(prefix); + if (!trailing) return providerPrefix; + return providerPrefix === '/' ? '/' : `${providerPrefix}/`; + } + /** * Opens a directory synchronously. * @param {string} dirPath The directory path @@ -1106,6 +1126,7 @@ class VirtualFileSystem { // Arrow functions capture `this` for private method access. const toProviderPath = (p) => this.#toProviderPath(p); + const toProviderPrefix = (p) => this.#toProviderPrefix(p); const toMountedPath = (p) => this.#toMountedPath(p); return ObjectFreeze({ @@ -1141,7 +1162,8 @@ class VirtualFileSystem { async mkdir(dirPath, options) { const providerPath = toProviderPath(dirPath); - return provider.mkdir(providerPath, options); + const created = await provider.mkdir(providerPath, options); + return created === undefined ? undefined : toMountedPath(created); }, async rmdir(dirPath) { @@ -1235,13 +1257,7 @@ class VirtualFileSystem { }, async mkdtemp(prefix) { - const providerPrefix = toProviderPath(prefix); - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - let suffix = ''; - for (let i = 0; i < 6; i++) { - suffix += chars[(MathRandom() * chars.length) | 0]; - } - const dirPath = providerPrefix + suffix; + const dirPath = toProviderPrefix(prefix) + randomSuffix(); await provider.mkdir(dirPath); return toMountedPath(dirPath); }, diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index a45cd47a9bf4..6e0170f4a0b4 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -511,9 +511,17 @@ function createVfsHandlers() { if (vfd) { vfd.entry.truncateSync(len); return true; } return undefined; }, - fchmodSync: noopFdSync, + fchmodSync(fd, mode) { + const vfd = getVirtualFd(fd); + if (vfd) { vfd.entry.chmodSync(mode); return true; } + return undefined; + }, fchownSync: noopFdSync, - futimesSync: noopFdSync, + futimesSync(fd, atime, mtime) { + const vfd = getVirtualFd(fd); + if (vfd) { vfd.entry.utimesSync(atime, mtime); return true; } + return undefined; + }, fdatasyncSync: noopFdSync, fsyncSync: noopFdSync, readvSync(fd, buffers, position) { @@ -570,9 +578,17 @@ function createVfsHandlers() { if (!vfd) return undefined; return vfd.entry.truncate(len).then(() => true); }, - fchmod: noopFd, + fchmod(fd, mode) { + const vfd = getVirtualFd(fd); + if (!vfd) return undefined; + return vfd.entry.chmod(mode).then(() => true); + }, fchown: noopFd, - futimes: noopFd, + futimes(fd, atime, mtime) { + const vfd = getVirtualFd(fd); + if (!vfd) return undefined; + return vfd.entry.utimes(atime, mtime).then(() => true); + }, fdatasync: noopFd, fsync: noopFd, @@ -605,10 +621,40 @@ function createVfsHandlers() { const pathStr = toPathStr(filename); if (pathStr !== null) { const r = findVFSForPath(pathStr); - if (r !== null) return r.vfs.watch(pathStr, options, listener); + if (r !== null) { + if (!r.vfs.existsSync(pathStr)) throw createENOENT('watch', pathStr); + return r.vfs.watch(pathStr, options, listener); + } } return undefined; }, + watchFile(filename, options, listener) { + const pathStr = toPathStr(filename); + if (pathStr === null) return undefined; + const r = findVFSForPath(pathStr); + if (r === null) return undefined; + if (options === null || typeof options !== 'object') { + listener = options; + options = kEmptyObject; + } + return r.vfs.watchFile(pathStr, options, listener); + }, + unwatchFile(filename, listener) { + const pathStr = toPathStr(filename); + if (pathStr === null) return undefined; + const r = findVFSForPath(pathStr); + if (r === null) return undefined; + r.vfs.unwatchFile(pathStr, listener); + return true; + }, + promisesWatch(filename, options) { + const pathStr = toPathStr(filename); + if (pathStr === null) return undefined; + const r = findVFSForPath(pathStr); + if (r === null) return undefined; + if (!r.vfs.existsSync(pathStr)) throw createENOENT('watch', pathStr); + return r.vfs.promises.watch(pathStr, options); + }, readdir(path, options) { const promise = vfsOp(path, (vfs, n) => vfs.promises.readdir(n, options)); diff --git a/test/parallel/test-vfs-file-handle.js b/test/parallel/test-vfs-file-handle.js index d9d919446b8e..4b86714fbc53 100644 --- a/test/parallel/test-vfs-file-handle.js +++ b/test/parallel/test-vfs-file-handle.js @@ -42,10 +42,17 @@ myVfs.writeFileSync('/file.txt', 'hello world'); assert.strictEqual(b1.toString(), 'hello'); assert.strictEqual(b2.toString(), ' world'); + // Metadata methods reach the entry the way fchmod(2)/futimes(2) do, and + // validate their arguments the way a FileHandle would. + await handle.chmod(0o600); + assert.strictEqual((await handle.stat()).mode & 0o777, 0o600); + await handle.utimes(1000, 2000); + assert.strictEqual((await handle.stat()).mtimeMs, 2000 * 1000); + await assert.rejects(handle.chmod(), { code: 'ERR_INVALID_ARG_TYPE' }); + await assert.rejects(handle.utimes(), { code: 'ERR_INVALID_ARG_TYPE' }); + // no-op metadata methods - await handle.chmod(); await handle.chown(); - await handle.utimes(); await handle.datasync(); await handle.sync(); diff --git a/test/parallel/test-vfs-fs-hook-gaps.js b/test/parallel/test-vfs-fs-hook-gaps.js new file mode 100644 index 000000000000..a500d796599d --- /dev/null +++ b/test/parallel/test-vfs-fs-hook-gaps.js @@ -0,0 +1,111 @@ +// Flags: --experimental-vfs +'use strict'; + +// `node:fs` entry points route mounted paths through the VFS hooks. Where a +// hook is missing, runs before argument validation, or ignores the +// descriptor form of an operation, the same call behaves differently from a +// real path. Each case states the real-fs outcome as the expectation. Cases +// are independent so the runner reports each one. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); +const { test } = require('node:test'); + +function mount(populate) { + const layer = vfs.create(); + populate?.(layer); + return layer.mount(); +} + +test('watchFile on a mounted path installs a stat watcher', () => { + const file = path.join(mount((l) => l.writeFileSync('/f', 'x')), 'f'); + fs.watchFile(file, { interval: 10 }, common.mustNotCall()); + fs.unwatchFile(file); +}); + +test('fs.promises.watch on a mounted directory yields events', async () => { + const dir = path.join(mount((l) => l.mkdirSync('/d')), 'd'); + const ac = new AbortController(); + const watcher = fs.promises.watch(dir, { signal: ac.signal }); + setTimeout(() => fs.writeFileSync(path.join(dir, 'x'), '1'), 20); + for await (const event of watcher) { + assert.strictEqual(event.filename, 'x'); + ac.abort(); + break; + } +}); + +test('watch on a missing mounted path throws ENOENT', () => { + const dir = mount(); + // Should the call return a watcher instead, it polls forever, so it is + // closed to let the process exit. + let watcher; + try { + assert.throws(() => { watcher = fs.watch(path.join(dir, 'nope')); }, + { code: 'ENOENT' }); + } finally { + watcher?.close(); + } +}); + +test('utimesSync accepts numeric strings as seconds', () => { + const file = path.join(mount((l) => l.writeFileSync('/f', 'x')), 'f'); + fs.utimesSync(file, '1000', '2000'); + assert.strictEqual(fs.statSync(file).mtimeMs, 2000 * 1000); +}); + +test('utimesSync rejects an invalid time argument', () => { + const file = path.join(mount((l) => l.writeFileSync('/f', 'x')), 'f'); + assert.throws(() => fs.utimesSync(file, {}, {}), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('readdirSync rejects an invalid encoding', () => { + const dir = mount(); + assert.throws(() => fs.readdirSync(dir, { encoding: 'nope' }), + { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +test('futimesSync updates the timestamps through a descriptor', () => { + const file = path.join(mount((l) => l.writeFileSync('/f', 'x')), 'f'); + const fd = fs.openSync(file, 'r+'); + try { + fs.futimesSync(fd, 1000, 2000); + } finally { + fs.closeSync(fd); + } + assert.strictEqual(fs.statSync(file).mtimeMs, 2000 * 1000); +}); + +test('fchmodSync changes the mode through a descriptor', () => { + const file = path.join(mount((l) => l.writeFileSync('/f', 'x')), 'f'); + const fd = fs.openSync(file, 'r+'); + try { + fs.fchmodSync(fd, 0o600); + } finally { + fs.closeSync(fd); + } + assert.strictEqual(fs.statSync(file).mode & 0o777, 0o600); +}); + +test('mkdtempSync with a trailing separator creates the directory inside the prefix', () => { + const dir = path.join(mount((l) => l.mkdirSync('/dir')), 'dir'); + const created = fs.mkdtempSync(dir + path.sep); + assert.ok(created.startsWith(dir + path.sep), `${created} is not inside ${dir}`); + assert.strictEqual(fs.statSync(created).isDirectory(), true); +}); + +test('mkdirSync({ recursive: true }) returns the first directory created', () => { + const dir = mount(); + const created = fs.mkdirSync(path.join(dir, 'a', 'b'), { recursive: true }); + assert.strictEqual(created, path.join(dir, 'a')); +}); + +test('a closed Dir can be disposed asynchronously', async () => { + const dir = mount((l) => l.mkdirSync('/d')); + const handle = fs.opendirSync(dir); + handle.closeSync(); + await handle[Symbol.asyncDispose](); +});