diff --git a/doc/api/cli.md b/doc/api/cli.md index b5428453787e4a..64cfe0f15daf69 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1373,6 +1373,16 @@ Enable module mocking in the test runner. This feature requires `--allow-worker` if used with the [Permission Model][]. +### `--experimental-vfs` + + + +> Stability: 1 - Experimental + +Enable the experimental [`node:vfs`][] module. + ### `--experimental-vm-modules` + + + +> Stability: 1 - Experimental + + + +The `node:vfs` module provides an in-memory virtual file system with an +`fs`-like API. It is useful for tests, fixtures, embedded assets, and other +scenarios where you need a self-contained file system without touching the +real disk. + +To access it: + +```mjs +import vfs from 'node:vfs'; +``` + +```cjs +const vfs = require('node:vfs'); +``` + +This module is only available under the `node:` scheme, and only when Node.js +is started with the `--experimental-vfs` flag. + +## Basic usage + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.mkdirSync('/dir', { recursive: true }); +myVfs.writeFileSync('/dir/hello.txt', 'Hello, VFS!'); + +console.log(myVfs.readFileSync('/dir/hello.txt', 'utf8')); // 'Hello, VFS!' +``` + +`vfs.create()` returns a [`VirtualFileSystem`][] instance backed by a +[`MemoryProvider`][] by default. The instance exposes synchronous, +callback-based, and promise-based file system methods that mirror the +shape of the [`node:fs`][] API. All paths are POSIX-style and absolute +(starting with `/`). + +## `vfs.create([provider][, options])` + + + +* `provider` {VirtualProvider} The provider to use. **Default:** + `new MemoryProvider()`. +* `options` {Object} + * `emitExperimentalWarning` {boolean} Whether to emit the experimental + warning when the instance is created. **Default:** `true`. +* Returns: {VirtualFileSystem} + +Convenience factory equivalent to `new VirtualFileSystem(provider, options)`. + +```cjs +const vfs = require('node:vfs'); + +// Default in-memory provider +const memoryVfs = vfs.create(); + +// Explicit provider +const realVfs = vfs.create(new vfs.RealFSProvider('/tmp/sandbox')); +``` + +## Class: `VirtualFileSystem` + + + +A `VirtualFileSystem` wraps a [`VirtualProvider`][] and exposes an +`fs`-like API. Each instance maintains its own file tree. + +### `new VirtualFileSystem([provider][, options])` + + + +* `provider` {VirtualProvider} The provider to use. **Default:** + `new MemoryProvider()`. +* `options` {Object} + * `emitExperimentalWarning` {boolean} Whether to emit the experimental + warning. **Default:** `true`. + +### `vfs.provider` + + + +* {VirtualProvider} + +The provider backing this VFS instance. + +### `vfs.readonly` + + + +* {boolean} + +`true` when the underlying provider is read-only. + +### File system methods + +`VirtualFileSystem` implements the following methods, with the same +signatures as their [`node:fs`][] counterparts: + +#### Synchronous methods + +* `existsSync(path)` +* `statSync(path[, options])` +* `lstatSync(path[, options])` +* `readFileSync(path[, options])` +* `writeFileSync(path, data[, options])` +* `appendFileSync(path, data[, options])` +* `readdirSync(path[, options])` +* `mkdirSync(path[, options])` +* `rmdirSync(path)` +* `unlinkSync(path)` +* `renameSync(oldPath, newPath)` +* `copyFileSync(src, dest[, mode])` +* `realpathSync(path[, options])` +* `readlinkSync(path[, options])` +* `symlinkSync(target, path[, type])` +* `accessSync(path[, mode])` +* `rmSync(path[, options])` +* `truncateSync(path[, len])` +* `ftruncateSync(fd[, len])` +* `linkSync(existingPath, newPath)` +* `chmodSync(path, mode)` +* `chownSync(path, uid, gid)` +* `utimesSync(path, atime, mtime)` +* `lutimesSync(path, atime, mtime)` +* `mkdtempSync(prefix)` +* `opendirSync(path[, options])` +* `openAsBlob(path[, options])` +* File-descriptor ops: `openSync`, `closeSync`, `readSync`, `writeSync`, + `fstatSync` +* Streams: `createReadStream`, `createWriteStream` +* Watchers: `watch`, `watchFile`, `unwatchFile` + +#### Callback-style asynchronous methods + +`readFile`, `writeFile`, `stat`, `lstat`, `readdir`, `realpath`, `readlink`, +`access`, `open`, `close`, `read`, `write`, `rm`, `fstat`, `truncate`, +`ftruncate`, `link`, `mkdtemp`, `opendir`. Each takes a Node.js-style +callback `(err, ...result)`. + +#### Promise methods + +`vfs.promises` exposes the promise-based variants: + +```cjs +const vfs = require('node:vfs'); + +async function example() { + const myVfs = vfs.create(); + await myVfs.promises.writeFile('/file.txt', 'hello'); + const data = await myVfs.promises.readFile('/file.txt', 'utf8'); + return data; +} +example(); +``` + +The promise namespace mirrors `fs.promises` and includes `readFile`, +`writeFile`, `appendFile`, `stat`, `lstat`, `readdir`, `mkdir`, `rmdir`, +`unlink`, `rename`, `copyFile`, `realpath`, `readlink`, `symlink`, +`access`, `rm`, `truncate`, `link`, `mkdtemp`, `chmod`, `chown`, `lchown`, +`utimes`, `lutimes`, `open`, `lchmod`, and `watch`. + +## Class: `VirtualProvider` + + + +The base class for all VFS providers. Subclasses implement the essential +primitives (`open`, `stat`, `readdir`, `mkdir`, `rmdir`, `unlink`, +`rename`, ...) and inherit default implementations of the derived +methods (`readFile`, `writeFile`, `exists`, `copyFile`, `access`, ...). + +### Capability flags + +* `provider.readonly` {boolean} **Default:** `false`. +* `provider.supportsSymlinks` {boolean} **Default:** `false`. +* `provider.supportsWatch` {boolean} **Default:** `false`. + +### Creating custom providers + +```cjs +const { VirtualProvider } = require('node:vfs'); + +class StaticProvider extends VirtualProvider { + get readonly() { return true; } + + statSync(path) { /* ... */ } + openSync(path, flags) { /* ... */ } + readdirSync(path, options) { /* ... */ } + // ... +} +``` + +The base class throws `ERR_METHOD_NOT_IMPLEMENTED` for any primitive +that has not been overridden, and rejects writes from a `readonly` +provider with `EROFS`. + +## Class: `MemoryProvider` + + + +The default in-memory provider. Stores files, directories, and symbolic +links in a `Map`-backed tree, supports symlinks (`supportsSymlinks === +true`), and supports watching (`supportsWatch === true`). + +### `memoryProvider.setReadOnly()` + + + +Locks the provider into read-only mode. Subsequent writes through any +[`VirtualFileSystem`][] using this provider throw `EROFS`. There is no +way to revert the provider to writable. + +```cjs +const vfs = require('node:vfs'); + +const provider = new vfs.MemoryProvider(); +const myVfs = vfs.create(provider); +myVfs.writeFileSync('/seed.txt', 'initial'); + +provider.setReadOnly(); + +myVfs.writeFileSync('/x.txt', 'fail'); // throws EROFS +``` + +## Class: `RealFSProvider` + + + +A provider that wraps a real file system directory and exposes its +contents through the VFS API. All VFS paths are resolved relative to +the root and verified to stay inside it; symbolic links resolving +outside the root are rejected. + +### `new RealFSProvider(rootPath)` + + + +* `rootPath` {string} The absolute file system path to use as the root. + Must be a non-empty string. + +```cjs +const vfs = require('node:vfs'); + +const realVfs = vfs.create(new vfs.RealFSProvider('/tmp/sandbox')); +realVfs.writeFileSync('/file.txt', 'hello'); // writes /tmp/sandbox/file.txt +``` + +### `realFSProvider.rootPath` + + + +* {string} + +The resolved absolute path used as the root. + +## Implementation details + +### `Stats` objects + +VFS `Stats` objects are real instances of [`fs.Stats`][] (or +[`fs.BigIntStats`][] when `{ bigint: true }` is requested). Their +fields use synthetic but stable values: + +* `dev` is `4085` (the VFS device id). +* `ino` is monotonically increasing per process. +* `blksize` is `4096`. +* `blocks` is `Math.ceil(size / 512)`. +* Times default to the moment the entry was created/last modified. + +[`MemoryProvider`]: #class-memoryprovider +[`VirtualFileSystem`]: #class-virtualfilesystem +[`VirtualProvider`]: #class-virtualprovider +[`fs.BigIntStats`]: fs.md#class-fsbigintstats +[`fs.Stats`]: fs.md#class-fsstats +[`node:fs`]: fs.md diff --git a/doc/node.1 b/doc/node.1 index 6604a480f7be29..bf5e87c400415e 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -755,6 +755,11 @@ Enable the experimental .Sy node:stream/iter module. . +.It Fl -experimental-vfs +Enable the experimental +.Sy node:vfs +module. +. .It Fl -experimental-sea-config Use this flag to generate a blob that can be injected into the Node.js binary to produce a single executable application. See the documentation @@ -1936,6 +1941,8 @@ one is included in the list below. .It \fB--experimental-top-level-await\fR .It +\fB--experimental-vfs\fR +.It \fB--experimental-vm-modules\fR .It \fB--experimental-wasi-unstable-preview1\fR diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index 0415763e360246..44e85643972221 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -130,9 +130,10 @@ const schemelessBlockList = new SafeSet([ 'quic', 'test', 'test/reporters', + 'vfs', ]); // Modules that will only be enabled at run time. -const experimentalModuleList = new SafeSet(['ffi', 'sqlite', 'quic', 'stream/iter', 'zlib/iter']); +const experimentalModuleList = new SafeSet(['ffi', 'sqlite', 'quic', 'stream/iter', 'zlib/iter', 'vfs']); // Set up process.binding() and process._linkedBinding(). { diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 16a80c2d4f410f..63cd12c7e2b814 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -117,6 +117,7 @@ function prepareExecution(options) { setupFFI(); setupSQLite(); setupStreamIter(); + setupVfs(); setupQuic(); setupWebStorage(); setupWebsocket(); @@ -421,6 +422,15 @@ function setupQuic() { BuiltinModule.allowRequireByUsers('quic'); } +function setupVfs() { + if (!getOptionValue('--experimental-vfs')) { + return; + } + + const { BuiltinModule } = require('internal/bootstrap/realm'); + BuiltinModule.allowRequireByUsers('vfs'); +} + function setupWebStorage() { if (getEmbedderOptions().noBrowserGlobals || !getOptionValue('--experimental-webstorage')) { diff --git a/lib/internal/vfs/dir.js b/lib/internal/vfs/dir.js new file mode 100644 index 00000000000000..ff428bcd2fb20f --- /dev/null +++ b/lib/internal/vfs/dir.js @@ -0,0 +1,109 @@ +'use strict'; + +const { + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, +} = primordials; + +const { + codes: { + ERR_DIR_CLOSED, + }, +} = require('internal/errors'); + +/** + * Virtual directory handle returned by VFS opendir/opendirSync. + * Mimics the subset of the native Dir interface used by Node.js internals + * (e.g. fs.cp, fs.promises.cp). + */ +class VirtualDir { + #path; + #entries; + #index; + #closed; + + constructor(dirPath, entries) { + this.#path = dirPath; + this.#entries = entries; + this.#index = 0; + this.#closed = false; + } + + get path() { + return this.#path; + } + + readSync() { + if (this.#closed) { + throw new ERR_DIR_CLOSED(); + } + if (this.#index >= this.#entries.length) { + return null; + } + return this.#entries[this.#index++]; + } + + async read(callback) { + if (typeof callback === 'function') { + try { + const result = this.readSync(); + process.nextTick(callback, null, result); + } catch (err) { + process.nextTick(callback, err); + } + return; + } + return this.readSync(); + } + + closeSync() { + if (this.#closed) { + throw new ERR_DIR_CLOSED(); + } + this.#closed = true; + } + + async close(callback) { + if (typeof callback === 'function') { + this.closeSync(); + process.nextTick(callback, null); + return; + } + this.closeSync(); + } + + async *entries() { + if (this.#closed) { + throw new ERR_DIR_CLOSED(); + } + try { + let entry; + while ((entry = this.readSync()) !== null) { + yield entry; + } + } finally { + if (!this.#closed) { + this.closeSync(); + } + } + } + + [SymbolAsyncIterator]() { + return this.entries(); + } + + [SymbolAsyncDispose]() { + return this.close(); + } + + [SymbolDispose]() { + if (!this.#closed) { + this.closeSync(); + } + } +} + +module.exports = { + VirtualDir, +}; diff --git a/lib/internal/vfs/errors.js b/lib/internal/vfs/errors.js new file mode 100644 index 00000000000000..79e4a647d133b1 --- /dev/null +++ b/lib/internal/vfs/errors.js @@ -0,0 +1,193 @@ +'use strict'; + +const { + ErrorCaptureStackTrace, +} = primordials; + +const { + UVException, +} = require('internal/errors'); + +const { + UV_ENOENT, + UV_ENOTDIR, + UV_ENOTEMPTY, + UV_EISDIR, + UV_EBADF, + UV_EEXIST, + UV_EROFS, + UV_EINVAL, + UV_ELOOP, + UV_EACCES, +} = internalBinding('uv'); + +/** + * Creates an ENOENT error for virtual file system operations. + * @param {string} syscall The system call name + * @param {string} path The path that was not found + * @returns {Error} + */ +function createENOENT(syscall, path) { + const err = new UVException({ + errno: UV_ENOENT, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createENOENT); + return err; +} + +/** + * Creates an ENOTDIR error. + * @param {string} syscall The system call name + * @param {string} path The path that is not a directory + * @returns {Error} + */ +function createENOTDIR(syscall, path) { + const err = new UVException({ + errno: UV_ENOTDIR, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createENOTDIR); + return err; +} + +/** + * Creates an ENOTEMPTY error for non-empty directory. + * @param {string} syscall The system call name + * @param {string} path The path of the non-empty directory + * @returns {Error} + */ +function createENOTEMPTY(syscall, path) { + const err = new UVException({ + errno: UV_ENOTEMPTY, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createENOTEMPTY); + return err; +} + +/** + * Creates an EISDIR error. + * @param {string} syscall The system call name + * @param {string} path The path that is a directory + * @returns {Error} + */ +function createEISDIR(syscall, path) { + const err = new UVException({ + errno: UV_EISDIR, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createEISDIR); + return err; +} + +/** + * Creates an EBADF error for invalid file descriptor operations. + * @param {string} syscall The system call name + * @returns {Error} + */ +function createEBADF(syscall) { + const err = new UVException({ + errno: UV_EBADF, + syscall, + }); + ErrorCaptureStackTrace(err, createEBADF); + return err; +} + +/** + * Creates an EEXIST error. + * @param {string} syscall The system call name + * @param {string} path The path that already exists + * @returns {Error} + */ +function createEEXIST(syscall, path) { + const err = new UVException({ + errno: UV_EEXIST, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createEEXIST); + return err; +} + +/** + * Creates an EROFS error for read-only file system. + * @param {string} syscall The system call name + * @param {string} path The path + * @returns {Error} + */ +function createEROFS(syscall, path) { + const err = new UVException({ + errno: UV_EROFS, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createEROFS); + return err; +} + +/** + * Creates an EINVAL error for invalid argument. + * @param {string} syscall The system call name + * @param {string} path The path + * @returns {Error} + */ +function createEINVAL(syscall, path) { + const err = new UVException({ + errno: UV_EINVAL, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createEINVAL); + return err; +} + +/** + * Creates an ELOOP error for too many symbolic links. + * @param {string} syscall The system call name + * @param {string} path The path + * @returns {Error} + */ +function createELOOP(syscall, path) { + const err = new UVException({ + errno: UV_ELOOP, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createELOOP); + return err; +} + +/** + * Creates an EACCES error for permission denied. + * @param {string} syscall The system call name + * @param {string} path The path + * @returns {Error} + */ +function createEACCES(syscall, path) { + const err = new UVException({ + errno: UV_EACCES, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createEACCES); + return err; +} + +module.exports = { + createENOENT, + createENOTDIR, + createENOTEMPTY, + createEISDIR, + createEBADF, + createEEXIST, + createEROFS, + createEINVAL, + createELOOP, + createEACCES, +}; diff --git a/lib/internal/vfs/fd.js b/lib/internal/vfs/fd.js new file mode 100644 index 00000000000000..bd36ad218f48b2 --- /dev/null +++ b/lib/internal/vfs/fd.js @@ -0,0 +1,87 @@ +'use strict'; + +const { + SafeMap, + Symbol, +} = primordials; + +// Private symbols +const kFd = Symbol('kFd'); +const kEntry = Symbol('kEntry'); + +// VFS FDs use bit 30 set to avoid conflicts with real OS fds. +// Real fds are small non-negative integers; VFS fds start at 0x40000000. +const VFS_FD_MASK = 0x40000000; +let nextFd = 0; + +// Global registry of open virtual file descriptors +const openFDs = new SafeMap(); + +/** + * Represents an open virtual file descriptor. + * Wraps a VirtualFileHandle from the provider. + */ +class VirtualFD { + /** + * @param {number} fd The file descriptor number + * @param {VirtualFileHandle} entry The virtual file handle + */ + constructor(fd, entry) { + this[kFd] = fd; + this[kEntry] = entry; + } + + /** + * Gets the file descriptor number. + * @returns {number} + */ + get fd() { + return this[kFd]; + } + + /** + * Gets the file handle. + * @returns {VirtualFileHandle} + */ + get entry() { + return this[kEntry]; + } +} + +/** + * Opens a virtual file and returns its file descriptor. + * @param {VirtualFileHandle} entry The virtual file handle + * @returns {number} The file descriptor + */ +function openVirtualFd(entry) { + const fd = VFS_FD_MASK | nextFd++; + const vfd = new VirtualFD(fd, entry); + openFDs.set(fd, vfd); + return fd; +} + +/** + * Gets a VirtualFD by its file descriptor number. + * @param {number} fd The file descriptor number + * @returns {VirtualFD|undefined} + */ +function getVirtualFd(fd) { + return openFDs.get(fd); +} + +/** + * Closes a virtual file descriptor. + * @param {number} fd The file descriptor number + * @returns {boolean} True if the fd was found and closed + */ +function closeVirtualFd(fd) { + return openFDs.delete(fd); +} + +module.exports = { + VFS_FD_MASK, + VirtualFD, + openVirtualFd, + getVirtualFd, + closeVirtualFd, +}; diff --git a/lib/internal/vfs/file_handle.js b/lib/internal/vfs/file_handle.js new file mode 100644 index 00000000000000..c64e340e2a4f96 --- /dev/null +++ b/lib/internal/vfs/file_handle.js @@ -0,0 +1,720 @@ +'use strict'; + +const { + DateNow, + MathMax, + MathMin, + Number, + Symbol, + SymbolAsyncDispose, +} = primordials; + +const { Buffer } = require('buffer'); +const { + codes: { + ERR_INVALID_STATE, + ERR_METHOD_NOT_IMPLEMENTED, + }, +} = require('internal/errors'); +const { + createEBADF, +} = require('internal/vfs/errors'); + +// Private symbols +const kPath = Symbol('kPath'); +const kFlags = Symbol('kFlags'); +const kMode = Symbol('kMode'); +const kPosition = Symbol('kPosition'); +const kClosed = Symbol('kClosed'); + +/** + * Base class for virtual file handles. + * Provides the interface that file handles must implement. + */ +class VirtualFileHandle { + /** + * @param {string} path The file path + * @param {string} flags The open flags + * @param {number} [mode] The file mode + */ + constructor(path, flags, mode) { + this[kPath] = path; + this[kFlags] = flags; + this[kMode] = mode ?? 0o644; + this[kPosition] = 0; + this[kClosed] = false; + } + + /** + * Gets the file path. + * @returns {string} + */ + get path() { + return this[kPath]; + } + + /** + * Gets the open flags. + * @returns {string} + */ + get flags() { + return this[kFlags]; + } + + /** + * Gets the file mode. + * @returns {number} + */ + get mode() { + return this[kMode]; + } + + /** + * Gets the current position. + * @returns {number} + */ + get position() { + return this[kPosition]; + } + + /** + * Sets the current position. + * @param {number} pos The new position + */ + set position(pos) { + this[kPosition] = pos; + } + + /** + * Returns true if the handle is closed. + * @returns {boolean} + */ + get closed() { + return this[kClosed]; + } + + /** + * Throws if the handle is closed. + * @param {string} syscall The syscall name for the error + */ + #checkClosed(syscall) { + if (this[kClosed]) { + throw createEBADF(syscall); + } + } + + /** + * Reads data from the file. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {Promise<{ bytesRead: number, buffer: Buffer }>} + */ + async read(buffer, offset, length, position) { + this.#checkClosed('read'); + throw new ERR_METHOD_NOT_IMPLEMENTED('read'); + } + + /** + * Reads data from the file synchronously. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + readSync(buffer, offset, length, position) { + this.#checkClosed('read'); + throw new ERR_METHOD_NOT_IMPLEMENTED('readSync'); + } + + /** + * Writes data to the file. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {Promise<{ bytesWritten: number, buffer: Buffer }>} + */ + async write(buffer, offset, length, position) { + this.#checkClosed('write'); + throw new ERR_METHOD_NOT_IMPLEMENTED('write'); + } + + /** + * Writes data to the file synchronously. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + writeSync(buffer, offset, length, position) { + this.#checkClosed('write'); + throw new ERR_METHOD_NOT_IMPLEMENTED('writeSync'); + } + + /** + * Reads the entire file. + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(options) { + this.#checkClosed('read'); + throw new ERR_METHOD_NOT_IMPLEMENTED('readFile'); + } + + /** + * Reads the entire file synchronously. + * @param {object|string} [options] Options or encoding + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + readFileSync(options) { + this.#checkClosed('read'); + throw new ERR_METHOD_NOT_IMPLEMENTED('readFileSync'); + } + + /** + * Writes data to the file (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + * @returns {Promise} + */ + async writeFile(data, options) { + this.#checkClosed('write'); + throw new ERR_METHOD_NOT_IMPLEMENTED('writeFile'); + } + + /** + * Writes data to the file synchronously (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(data, options) { + this.#checkClosed('write'); + throw new ERR_METHOD_NOT_IMPLEMENTED('writeFileSync'); + } + + /** + * Gets file stats. + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(options) { + this.#checkClosed('fstat'); + throw new ERR_METHOD_NOT_IMPLEMENTED('stat'); + } + + /** + * Gets file stats synchronously. + * @param {object} [options] Options + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + statSync(options) { + this.#checkClosed('fstat'); + throw new ERR_METHOD_NOT_IMPLEMENTED('statSync'); + } + + /** + * Truncates the file. + * @param {number} [len] The new length + * @returns {Promise} + */ + async truncate(len) { + this.#checkClosed('ftruncate'); + throw new ERR_METHOD_NOT_IMPLEMENTED('truncate'); + } + + /** + * Truncates the file synchronously. + * @param {number} [len] The new length + */ + truncateSync(len) { + this.#checkClosed('ftruncate'); + throw new ERR_METHOD_NOT_IMPLEMENTED('truncateSync'); + } + + /** + * No-op chmod - VFS files don't have real permissions. + * @returns {Promise} + */ + async chmod() {} + + /** + * No-op chown - VFS files don't have real ownership. + * @returns {Promise} + */ + async chown() {} + + /** + * No-op utimes - timestamps are handled by the provider. + * @returns {Promise} + */ + async utimes() {} + + /** + * No-op datasync - VFS is in-memory. + * @returns {Promise} + */ + async datasync() {} + + /** + * No-op sync - VFS is in-memory. + * @returns {Promise} + */ + async sync() {} + + /** + * Reads data from the file into multiple buffers. + * @param {Buffer[]} buffers The buffers to read into + * @param {number|null} [position] The position to read from + * @returns {Promise<{ bytesRead: number, buffers: Buffer[] }>} + */ + async readv(buffers, position) { + this.#checkClosed('readv'); + let totalRead = 0; + for (let i = 0; i < buffers.length; i++) { + const buf = buffers[i]; + const pos = position != null ? position + totalRead : null; + const { bytesRead } = await this.read(buf, 0, buf.byteLength, pos); + totalRead += bytesRead; + if (bytesRead < buf.byteLength) break; + } + return { __proto__: null, bytesRead: totalRead, buffers }; + } + + /** + * Writes data from multiple buffers to the file. + * @param {Buffer[]} buffers The buffers to write from + * @param {number|null} [position] The position to write to + * @returns {Promise<{ bytesWritten: number, buffers: Buffer[] }>} + */ + async writev(buffers, position) { + this.#checkClosed('writev'); + let totalWritten = 0; + for (let i = 0; i < buffers.length; i++) { + const buf = buffers[i]; + const pos = position != null ? position + totalWritten : null; + const { bytesWritten } = await this.write(buf, 0, buf.byteLength, pos); + totalWritten += bytesWritten; + if (bytesWritten < buf.byteLength) break; + } + return { __proto__: null, bytesWritten: totalWritten, buffers }; + } + + /** + * Appends data to the file. + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + * @returns {Promise} + */ + async appendFile(data, options) { + this.#checkClosed('appendFile'); + const buffer = typeof data === 'string' ? + Buffer.from(data, options?.encoding) : data; + await this.write(buffer, 0, buffer.length, null); + } + + readableWebStream() { + throw new ERR_METHOD_NOT_IMPLEMENTED('readableWebStream'); + } + + readLines() { + throw new ERR_METHOD_NOT_IMPLEMENTED('readLines'); + } + + createReadStream() { + throw new ERR_METHOD_NOT_IMPLEMENTED('createReadStream'); + } + + createWriteStream() { + throw new ERR_METHOD_NOT_IMPLEMENTED('createWriteStream'); + } + + /** + * Closes the file handle. + * @returns {Promise} + */ + async close() { + this[kClosed] = true; + } + + /** + * Closes the file handle synchronously. + */ + closeSync() { + this[kClosed] = true; + } + + [SymbolAsyncDispose]() { + return this.close(); + } +} + +/** + * A file handle for in-memory file content. + * Used by MemoryProvider and similar providers. + */ +class MemoryFileHandle extends VirtualFileHandle { + #content; + #size; + #entry; + #getStats; + + #checkClosed(syscall) { + if (this.closed) { + throw createEBADF(syscall); + } + } + + /** + * @param {string} path The file path + * @param {string} flags The open flags + * @param {number} [mode] The file mode + * @param {Buffer} content The initial file content + * @param {object} entry The entry object (for updating content) + * @param {Function} getStats Function to get updated stats + */ + constructor(path, flags, mode, content, entry, getStats) { + super(path, flags, mode); + this.#content = content; + this.#size = content.length; + this.#entry = entry; + this.#getStats = getStats; + + // Handle different open modes + if (flags === 'w' || flags === 'w+' || + flags === 'wx' || flags === 'wx+') { + // Write mode: truncate + this.#content = Buffer.alloc(0); + this.#size = 0; + if (entry) { + entry.content = this.#content; + } + } else if (flags === 'a' || flags === 'a+' || + flags === 'ax' || flags === 'ax+') { + // Append mode: position at end + this.position = this.#size; + } + } + + /** + * Throws EBADF if the handle was not opened for writing. + */ + #checkWritable() { + if (this.flags === 'r') { + throw createEBADF('write'); + } + } + + /** + * Throws EBADF if the handle was not opened for reading. + */ + #checkReadable() { + const f = this.flags; + if (f === 'w' || f === 'a' || f === 'wx' || f === 'ax') { + throw createEBADF('read'); + } + } + + /** + * Returns true if this handle was opened in append mode. + * @returns {boolean} + */ + #isAppend() { + const f = this.flags; + return f === 'a' || f === 'a+' || f === 'ax' || f === 'ax+'; + } + + /** + * Gets the current content synchronously. + * For dynamic content providers, this gets fresh content from the entry. + * @returns {Buffer} + */ + get content() { + // If entry has a dynamic content provider, get fresh content sync + if (this.#entry?.isDynamic && this.#entry.isDynamic()) { + return this.#entry.getContentSync(); + } + return this.#content.subarray(0, this.#size); + } + + /** + * Gets the current content asynchronously. + * For dynamic content providers, this gets fresh content from the entry. + * @returns {Promise} + */ + async getContentAsync() { + // If entry has a dynamic content provider, get fresh content async + if (this.#entry?.getContentAsync) { + return this.#entry.getContentAsync(); + } + return this.#content; + } + + /** + * Reads data from the file synchronously. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {number} The number of bytes read + */ + readSync(buffer, offset, length, position) { + this.#checkClosed('read'); + this.#checkReadable(); + + // Get content (resolves dynamic content providers) + const content = this.content; + const readPos = position !== null && position !== undefined ? + Number(position) : this.position; + const available = content.length - readPos; + + if (available <= 0) { + return 0; + } + + const bytesToRead = MathMin(length, available); + content.copy(buffer, offset, readPos, readPos + bytesToRead); + + // Update position if not using explicit position + if (position === null || position === undefined) { + this.position = readPos + bytesToRead; + } + + return bytesToRead; + } + + /** + * Reads data from the file. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {Promise<{ bytesRead: number, buffer: Buffer }>} + */ + async read(buffer, offset, length, position) { + const bytesRead = this.readSync(buffer, offset, length, position); + return { __proto__: null, bytesRead, buffer }; + } + + /** + * Writes data to the file synchronously. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {number} The number of bytes written + */ + writeSync(buffer, offset, length, position) { + this.#checkClosed('write'); + this.#checkWritable(); + + // In append mode, always write at the end + const writePos = this.#isAppend() ? + this.#size : + (position !== null && position !== undefined ? + Number(position) : this.position); + const data = buffer.subarray(offset, offset + length); + + // Expand buffer if needed (geometric doubling for amortized O(1) appends) + const neededSize = writePos + length; + if (neededSize > this.#content.length) { + const newCapacity = MathMax(neededSize, this.#content.length * 2); + const newContent = Buffer.alloc(newCapacity); + this.#content.copy(newContent, 0, 0, this.#size); + this.#content = newContent; + } + + // Write the data + data.copy(this.#content, writePos); + + // Update actual content size + if (neededSize > this.#size) { + this.#size = neededSize; + } + + // Update the entry's content, mtime, and ctime + if (this.#entry) { + const now = DateNow(); + this.#entry.content = this.#content.subarray(0, this.#size); + this.#entry.mtime = now; + this.#entry.ctime = now; + } + + // Update position if not using explicit position + if (position === null || position === undefined) { + this.position = writePos + length; + } + + return length; + } + + /** + * Writes data to the file. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {Promise<{ bytesWritten: number, buffer: Buffer }>} + */ + async write(buffer, offset, length, position) { + const bytesWritten = this.writeSync(buffer, offset, length, position); + return { __proto__: null, bytesWritten, buffer }; + } + + /** + * Reads the entire file synchronously. + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(options) { + this.#checkClosed('read'); + this.#checkReadable(); + + // Get content (resolves dynamic content providers) + const content = this.content; + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return Buffer.from(content); + } + + /** + * Reads the entire file. + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(options) { + this.#checkClosed('read'); + this.#checkReadable(); + + // Get content asynchronously (supports async content providers) + const content = await this.getContentAsync(); + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return Buffer.from(content); + } + + /** + * Writes data to the file synchronously. + * Replaces content in 'w' mode, appends in 'a' mode. + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(data, options) { + this.#checkClosed('write'); + this.#checkWritable(); + + const buffer = typeof data === 'string' ? Buffer.from(data, options?.encoding) : data; + + // In append mode, append to existing content + if (this.#isAppend()) { + const neededSize = this.#size + buffer.length; + if (neededSize > this.#content.length) { + const newCapacity = MathMax(neededSize, this.#content.length * 2); + const newContent = Buffer.alloc(newCapacity); + this.#content.copy(newContent, 0, 0, this.#size); + this.#content = newContent; + } + buffer.copy(this.#content, this.#size); + this.#size = neededSize; + } else { + this.#content = Buffer.from(buffer); + this.#size = buffer.length; + } + + // Update the entry's content, mtime, and ctime + if (this.#entry) { + const now = DateNow(); + this.#entry.content = this.#content.subarray(0, this.#size); + this.#entry.mtime = now; + this.#entry.ctime = now; + } + + this.position = this.#size; + } + + /** + * Writes data to the file (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + * @returns {Promise} + */ + async writeFile(data, options) { + this.writeFileSync(data, options); + } + + /** + * Gets file stats synchronously. + * @param {object} [options] Options + * @returns {Stats} + */ + statSync(options) { + this.#checkClosed('fstat'); + if (this.#getStats) { + return this.#getStats(this.#size); + } + throw new ERR_INVALID_STATE('stats not available'); + } + + /** + * Gets file stats. + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(options) { + return this.statSync(options); + } + + /** + * Truncates the file synchronously. + * @param {number} [len] The new length + */ + truncateSync(len = 0) { + this.#checkClosed('ftruncate'); + this.#checkWritable(); + + if (len < this.#size) { + // Zero out truncated region to avoid stale data + this.#content.fill(0, len, this.#size); + this.#size = len; + } else if (len > this.#size) { + if (len > this.#content.length) { + const newContent = Buffer.alloc(len); + this.#content.copy(newContent, 0, 0, this.#size); + this.#content = newContent; + } else { + // Buffer has enough capacity, just zero-fill the extension + this.#content.fill(0, this.#size, len); + } + this.#size = len; + } + + // Update the entry's content, mtime, and ctime + if (this.#entry) { + const now = DateNow(); + this.#entry.content = this.#content.subarray(0, this.#size); + this.#entry.mtime = now; + this.#entry.ctime = now; + } + } + + /** + * Truncates the file. + * @param {number} [len] The new length + * @returns {Promise} + */ + async truncate(len) { + this.truncateSync(len); + } +} + +module.exports = { + VirtualFileHandle, + MemoryFileHandle, +}; diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js new file mode 100644 index 00000000000000..1b84787f2318fd --- /dev/null +++ b/lib/internal/vfs/file_system.js @@ -0,0 +1,1147 @@ +'use strict'; + +const { + MathRandom, + ObjectFreeze, + Symbol, +} = primordials; + +const { validateBoolean } = require('internal/validators'); +const { MemoryProvider } = require('internal/vfs/providers/memory'); +const path = require('path'); +const { join: joinPath } = path; +const { + openVirtualFd, + getVirtualFd, + closeVirtualFd, +} = require('internal/vfs/fd'); +const { + 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'); + +// Private symbols +const kProvider = Symbol('kProvider'); +const kPromises = Symbol('kPromises'); + +/** + * Virtual File System implementation using Provider architecture. + * Wraps a Provider and exposes an fs-like API operating on + * provider-relative paths. + */ +class VirtualFileSystem { + /** + * @param {VirtualProvider|object} [providerOrOptions] The provider to use, or options + * @param {object} [options] Configuration options + * @param {boolean} [options.emitExperimentalWarning] Emit the experimental warning (default: true) + */ + constructor(providerOrOptions, options = kEmptyObject) { + + // Handle case where first arg is options object (no provider) + let provider = null; + if (providerOrOptions !== undefined && providerOrOptions !== null) { + if (typeof providerOrOptions.openSync === 'function') { + // It's a provider + provider = providerOrOptions; + } else if (typeof providerOrOptions === 'object') { + // It's options (no provider specified) + options = providerOrOptions; + provider = null; + } + } + + if (options.emitExperimentalWarning !== undefined) { + validateBoolean(options.emitExperimentalWarning, 'options.emitExperimentalWarning'); + } + + if (options.emitExperimentalWarning !== false) { + emitExperimentalWarning('VirtualFileSystem'); + } + + this[kProvider] = provider ?? new MemoryProvider(); + this[kPromises] = null; // Lazy-initialized + } + + /** + * Gets the underlying provider. + * @returns {VirtualProvider} + */ + get provider() { + return this[kProvider]; + } + + /** + * Returns true if the provider is read-only. + * @returns {boolean} + */ + get readonly() { + return this[kProvider].readonly; + } + + // ==================== Path Resolution ==================== + + /** + * Normalizes a path to a provider-relative POSIX path. + * @param {string} inputPath The path to normalize + * @returns {string} + */ + #toProviderPath(inputPath) { + return path.posix.normalize(inputPath); + } + + // ==================== FS Operations (Sync) ==================== + + /** + * Checks if a path exists synchronously. + * @param {string} filePath The path to check + * @returns {boolean} + */ + existsSync(filePath) { + try { + const providerPath = this.#toProviderPath(filePath); + return this[kProvider].existsSync(providerPath); + } catch { + return false; + } + } + + /** + * Gets stats for a path synchronously. + * @param {string} filePath The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + statSync(filePath, options) { + const providerPath = this.#toProviderPath(filePath); + return this[kProvider].statSync(providerPath, options); + } + + /** + * Gets stats for a path synchronously without following symlinks. + * @param {string} filePath The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + lstatSync(filePath, options) { + const providerPath = this.#toProviderPath(filePath); + return this[kProvider].lstatSync(providerPath, options); + } + + /** + * Reads a file synchronously. + * @param {string} filePath The path to read + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(filePath, options) { + const providerPath = this.#toProviderPath(filePath); + return this[kProvider].readFileSync(providerPath, options); + } + + /** + * Writes a file synchronously. + * @param {string} filePath The path to write + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(filePath, data, options) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].writeFileSync(providerPath, data, options); + } + + /** + * Appends to a file synchronously. + * @param {string} filePath The path to append to + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + */ + appendFileSync(filePath, data, options) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].appendFileSync(providerPath, data, options); + } + + /** + * Reads directory contents synchronously. + * @param {string} dirPath The directory path + * @param {object} [options] Options + * @returns {string[]|Dirent[]} + */ + readdirSync(dirPath, options) { + const providerPath = this.#toProviderPath(dirPath); + const result = this[kProvider].readdirSync(providerPath, options); + + // Fix Dirent parentPath from provider-relative to actual VFS path + if (options?.withFileTypes === true) { + const recursive = options?.recursive === true; + for (let i = 0; i < result.length; i++) { + const dirent = result[i]; + if (recursive) { + // In recursive mode, name may contain slashes (e.g. 'a/b.txt'). + // Fix to basename only and set correct parentPath. + const slashIdx = dirent.name.lastIndexOf('/'); + if (slashIdx !== -1) { + const subdir = dirent.name.slice(0, slashIdx); + dirent.parentPath = joinPath(dirPath, subdir); + dirent.name = dirent.name.slice(slashIdx + 1); + } else { + dirent.parentPath = dirPath; + } + } else { + dirent.parentPath = dirPath; + } + } + } + + return result; + } + + /** + * Creates a directory synchronously. + * @param {string} dirPath The directory path + * @param {object} [options] Options + * @returns {string|undefined} + */ + mkdirSync(dirPath, options) { + const providerPath = this.#toProviderPath(dirPath); + return this[kProvider].mkdirSync(providerPath, options); + } + + /** + * Removes a directory synchronously. + * @param {string} dirPath The directory path + */ + rmdirSync(dirPath) { + const providerPath = this.#toProviderPath(dirPath); + this[kProvider].rmdirSync(providerPath); + } + + /** + * Removes a file synchronously. + * @param {string} filePath The file path + */ + unlinkSync(filePath) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].unlinkSync(providerPath); + } + + /** + * Renames a file or directory synchronously. + * @param {string} oldPath The old path + * @param {string} newPath The new path + */ + renameSync(oldPath, newPath) { + const oldProviderPath = this.#toProviderPath(oldPath); + const newProviderPath = this.#toProviderPath(newPath); + this[kProvider].renameSync(oldProviderPath, newProviderPath); + } + + /** + * Copies a file synchronously. + * @param {string} src Source path + * @param {string} dest Destination path + * @param {number} [mode] Copy mode flags + */ + copyFileSync(src, dest, mode) { + const srcProviderPath = this.#toProviderPath(src); + const destProviderPath = this.#toProviderPath(dest); + this[kProvider].copyFileSync(srcProviderPath, destProviderPath, mode); + } + + /** + * Gets the real path by resolving all symlinks. + * @param {string} filePath The path + * @param {object} [options] Options + * @returns {string} + */ + realpathSync(filePath, options) { + const providerPath = this.#toProviderPath(filePath); + return this[kProvider].realpathSync(providerPath, options); + } + + /** + * Reads the target of a symbolic link. + * @param {string} linkPath The symlink path + * @param {object} [options] Options + * @returns {string} + */ + readlinkSync(linkPath, options) { + const providerPath = this.#toProviderPath(linkPath); + return this[kProvider].readlinkSync(providerPath, options); + } + + /** + * Creates a symbolic link. + * @param {string} target The symlink target + * @param {string} path The symlink path + * @param {string} [type] The symlink type + */ + symlinkSync(target, path, type) { + const providerPath = this.#toProviderPath(path); + this[kProvider].symlinkSync(target, providerPath, type); + } + + /** + * Checks file accessibility synchronously. + * @param {string} filePath The path to check + * @param {number} [mode] Access mode + */ + accessSync(filePath, mode) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].accessSync(providerPath, mode); + } + + /** + * Removes a file or directory synchronously. + * @param {string} filePath The path to remove + * @param {object} [options] Options + * @param {boolean} [options.recursive] If true, remove directories recursively + * @param {boolean} [options.force] If true, ignore ENOENT errors + */ + rmSync(filePath, options) { + const recursive = options?.recursive === true; + const force = options?.force === true; + + let stats; + try { + stats = this.lstatSync(filePath); + } catch (err) { + if (force && err?.code === 'ENOENT') return; + throw err; + } + + // Symlinks should be unlinked directly, never recursed into + if (stats.isSymbolicLink()) { + this.unlinkSync(filePath); + return; + } + + if (stats.isDirectory()) { + if (!recursive) { + throw createEISDIR('rm', filePath); + } + const entries = this.readdirSync(filePath); + for (let i = 0; i < entries.length; i++) { + this.rmSync(joinPath(filePath, entries[i]), options); + } + this.rmdirSync(filePath); + } else { + this.unlinkSync(filePath); + } + } + + // ==================== Additional Sync Operations ==================== + + /** + * Truncates a file synchronously. + * @param {string} filePath The file path + * @param {number} [len] The new length + */ + truncateSync(filePath, len = 0) { + if (len < 0) len = 0; + const providerPath = this.#toProviderPath(filePath); + const handle = this[kProvider].openSync(providerPath, 'r+'); + try { + handle.truncateSync(len); + } finally { + handle.closeSync(); + } + } + + /** + * Truncates a file descriptor synchronously. + * @param {number} fd The file descriptor + * @param {number} [len] The new length + */ + ftruncateSync(fd, len = 0) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('ftruncate'); + } + vfd.entry.truncateSync(len); + } + + /** + * Creates a hard link synchronously. + * @param {string} existingPath The existing file path + * @param {string} newPath The new link path + */ + linkSync(existingPath, newPath) { + const existingProviderPath = this.#toProviderPath(existingPath); + const newProviderPath = this.#toProviderPath(newPath); + this[kProvider].linkSync(existingProviderPath, newProviderPath); + } + + chmodSync(filePath, mode) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].chmodSync(providerPath, mode); + } + + chownSync(filePath, uid, gid) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].chownSync(providerPath, uid, gid); + } + + utimesSync(filePath, atime, mtime) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].utimesSync(providerPath, atime, mtime); + } + + lutimesSync(filePath, atime, mtime) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].lutimesSync(providerPath, atime, mtime); + } + + /** + * Creates a unique temporary directory synchronously. + * @param {string} prefix The prefix for the temp directory + * @returns {string} The full path of the created directory + */ + mkdtempSync(prefix) { + const providerPrefix = this.#toProviderPath(prefix); + // Generate random 6-character suffix like Node does + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let suffix = ''; + for (let i = 0; i < 6; i++) { + suffix += chars[(MathRandom() * chars.length) | 0]; + } + const dirPath = providerPrefix + suffix; + this[kProvider].mkdirSync(dirPath); + return dirPath; + } + + /** + * Opens a directory synchronously. + * @param {string} dirPath The directory path + * @param {object} [options] Options + * @returns {VirtualDir} A directory handle + */ + opendirSync(dirPath, options) { + const entries = this.readdirSync(dirPath, { + withFileTypes: true, + recursive: options?.recursive, + }); + return new VirtualDir(dirPath, entries); + } + + /** + * Opens a file as a Blob. + * @param {string} filePath The file path + * @param {object} [options] Options + * @returns {Blob} The file content as a Blob + */ + openAsBlob(filePath, options) { + const { Blob } = require('buffer'); + const providerPath = this.#toProviderPath(filePath); + const content = this[kProvider].readFileSync(providerPath); + const type = options?.type || ''; + return new Blob([content], { type }); + } + + // ==================== File Descriptor Operations ==================== + + /** + * Opens a file synchronously and returns a file descriptor. + * @param {string} filePath The path to open + * @param {string} [flags] Open flags + * @param {number} [mode] File mode + * @returns {number} The file descriptor + */ + openSync(filePath, flags = 'r', mode) { + const providerPath = this.#toProviderPath(filePath); + const handle = this[kProvider].openSync(providerPath, flags, mode); + return openVirtualFd(handle); + } + + /** + * Closes a file descriptor synchronously. + * @param {number} fd The file descriptor + */ + closeSync(fd) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('close'); + } + vfd.entry.closeSync(); + closeVirtualFd(fd); + } + + /** + * Reads from a file descriptor synchronously. + * @param {number} fd The file descriptor + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer + * @param {number} length The number of bytes to read + * @param {number|null} position The position in the file + * @returns {number} The number of bytes read + */ + readSync(fd, buffer, offset, length, position) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('read'); + } + return vfd.entry.readSync(buffer, offset, length, position); + } + + /** + * Writes to a file descriptor synchronously. + * @param {number} fd The file descriptor + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer + * @param {number} length The number of bytes to write + * @param {number|null} position The position in the file + * @returns {number} The number of bytes written + */ + writeSync(fd, buffer, offset, length, position) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('write'); + } + return vfd.entry.writeSync(buffer, offset, length, position); + } + + /** + * Gets file stats from a file descriptor synchronously. + * @param {number} fd The file descriptor + * @param {object} [options] Options + * @returns {Stats} + */ + fstatSync(fd, options) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('fstat'); + } + return vfd.entry.statSync(options); + } + + // ==================== FS Operations (Async with Callbacks) ==================== + + /** + * Reads a file asynchronously. + * @param {string} filePath The path to read + * @param {object|string|Function} [options] Options, encoding, or callback + * @param {Function} [callback] Callback (err, data) + */ + readFile(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].readFile(this.#toProviderPath(filePath), options) + .then((data) => callback(null, data), (err) => callback(err)); + } + + /** + * Writes a file asynchronously. + * @param {string} filePath The path to write + * @param {Buffer|string} data The data to write + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err) + */ + writeFile(filePath, data, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].writeFile(this.#toProviderPath(filePath), data, options) + .then(() => callback(null), (err) => callback(err)); + } + + /** + * Gets stats for a path asynchronously. + * @param {string} filePath The path to stat + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + stat(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].stat(this.#toProviderPath(filePath), options) + .then((stats) => callback(null, stats), (err) => callback(err)); + } + + /** + * Gets stats without following symlinks asynchronously. + * @param {string} filePath The path to stat + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + lstat(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].lstat(this.#toProviderPath(filePath), options) + .then((stats) => callback(null, stats), (err) => callback(err)); + } + + /** + * Reads directory contents asynchronously. + * @param {string} dirPath The directory path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, entries) + */ + readdir(dirPath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].readdir(this.#toProviderPath(dirPath), options) + .then((entries) => callback(null, entries), (err) => callback(err)); + } + + /** + * Gets the real path asynchronously. + * @param {string} filePath The path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, resolvedPath) + */ + realpath(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].realpath(this.#toProviderPath(filePath), options) + .then((realPath) => callback(null, realPath), (err) => callback(err)); + } + + /** + * Reads symlink target asynchronously. + * @param {string} linkPath The symlink path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, target) + */ + readlink(linkPath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].readlink(this.#toProviderPath(linkPath), options) + .then((target) => callback(null, target), (err) => callback(err)); + } + + /** + * Checks file accessibility asynchronously. + * @param {string} filePath The path to check + * @param {number|Function} [mode] Access mode or callback + * @param {Function} [callback] Callback (err) + */ + access(filePath, mode, callback) { + if (typeof mode === 'function') { + callback = mode; + mode = undefined; + } + + this[kProvider].access(this.#toProviderPath(filePath), mode) + .then(() => callback(null), (err) => callback(err)); + } + + /** + * Opens a file asynchronously. + * @param {string} filePath The path to open + * @param {string|Function} [flags] Open flags or callback + * @param {number|Function} [mode] File mode or callback + * @param {Function} [callback] Callback (err, fd) + */ + open(filePath, flags, mode, callback) { + if (typeof flags === 'function') { + callback = flags; + flags = 'r'; + mode = undefined; + } else if (typeof mode === 'function') { + callback = mode; + mode = undefined; + } + + const providerPath = this.#toProviderPath(filePath); + this[kProvider].open(providerPath, flags, mode) + .then((handle) => { + const fd = openVirtualFd(handle); + callback(null, fd); + }, (err) => callback(err)); + } + + /** + * Closes a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {Function} callback Callback (err) + */ + close(fd, callback) { + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('close')); + return; + } + + vfd.entry.close() + .then(() => { + closeVirtualFd(fd); + callback(null); + }, (err) => callback(err)); + } + + /** + * Reads from a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer + * @param {number} length The number of bytes to read + * @param {number|null} position The position in the file + * @param {Function} callback Callback (err, bytesRead, buffer) + */ + read(fd, buffer, offset, length, position, callback) { + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('read')); + return; + } + + vfd.entry.read(buffer, offset, length, position) + .then(({ bytesRead }) => callback(null, bytesRead, buffer), (err) => callback(err)); + } + + /** + * Writes to a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer + * @param {number} length The number of bytes to write + * @param {number|null} position The position in the file + * @param {Function} callback Callback (err, bytesWritten, buffer) + */ + write(fd, buffer, offset, length, position, callback) { + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('write')); + return; + } + + vfd.entry.write(buffer, offset, length, position) + .then(({ bytesWritten }) => callback(null, bytesWritten, buffer), (err) => callback(err)); + } + + /** + * Removes a file or directory asynchronously. + * @param {string} filePath The path to remove + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err) + */ + rm(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + try { + this.rmSync(filePath, options); + process.nextTick(callback, null); + } catch (err) { + process.nextTick(callback, err); + } + } + + /** + * Gets file stats from a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + fstat(fd, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('fstat')); + return; + } + + vfd.entry.stat(options) + .then((stats) => callback(null, stats), (err) => callback(err)); + } + + /** + * Truncates a file asynchronously. + * @param {string} filePath The file path + * @param {number|Function} [len] The new length or callback + * @param {Function} [callback] Callback (err) + */ + truncate(filePath, len, callback) { + if (typeof len === 'function') { + callback = len; + len = 0; + } + try { + this.truncateSync(filePath, len); + process.nextTick(callback, null); + } catch (err) { + process.nextTick(callback, err); + } + } + + /** + * Truncates a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {number|Function} [len] The new length or callback + * @param {Function} [callback] Callback (err) + */ + ftruncate(fd, len, callback) { + if (typeof len === 'function') { + callback = len; + len = 0; + } + try { + this.ftruncateSync(fd, len); + process.nextTick(callback, null); + } catch (err) { + process.nextTick(callback, err); + } + } + + /** + * Creates a hard link asynchronously. + * @param {string} existingPath The existing file path + * @param {string} newPath The new link path + * @param {Function} callback Callback (err) + */ + link(existingPath, newPath, callback) { + try { + this.linkSync(existingPath, newPath); + process.nextTick(callback, null); + } catch (err) { + process.nextTick(callback, err); + } + } + + /** + * Creates a unique temporary directory asynchronously. + * @param {string} prefix The prefix for the temp directory + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, dirPath) + */ + mkdtemp(prefix, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + try { + const dirPath = this.mkdtempSync(prefix); + process.nextTick(callback, null, dirPath); + } catch (err) { + process.nextTick(callback, err); + } + } + + /** + * Opens a directory asynchronously. + * @param {string} dirPath The directory path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, dir) + */ + opendir(dirPath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + try { + const dir = this.opendirSync(dirPath, options); + process.nextTick(callback, null, dir); + } catch (err) { + process.nextTick(callback, err); + } + } + + // ==================== Stream Operations ==================== + + /** + * Creates a readable stream for a virtual file. + * @param {string} filePath The path to the file + * @param {object} [options] Stream options + * @returns {ReadStream} + */ + createReadStream(filePath, options) { + return new VirtualReadStream(this, filePath, options); + } + + /** + * Creates a writable stream for a virtual file. + * @param {string} filePath The path to the file + * @param {object} [options] Stream options + * @returns {WriteStream} + */ + createWriteStream(filePath, options) { + return new VirtualWriteStream(this, filePath, options); + } + + // ==================== Watch Operations ==================== + + /** + * Watches a file or directory for changes. + * @param {string} filePath The path to watch + * @param {object|Function} [options] Watch options or listener + * @param {Function} [listener] Change listener + * @returns {EventEmitter} A watcher that emits 'change' events + */ + watch(filePath, options, listener) { + if (typeof options === 'function') { + listener = options; + options = {}; + } + + const providerPath = this.#toProviderPath(filePath); + const watcher = this[kProvider].watch(providerPath, options); + + if (listener) { + watcher.on('change', listener); + } + + return watcher; + } + + /** + * Watches a file for changes using stat polling. + * @param {string} filePath The path to watch + * @param {object|Function} [options] Watch options or listener + * @param {Function} [listener] Change listener + * @returns {EventEmitter} A stat watcher that emits 'change' events + */ + watchFile(filePath, options, listener) { + if (typeof options === 'function') { + listener = options; + options = {}; + } + + const providerPath = this.#toProviderPath(filePath); + return this[kProvider].watchFile(providerPath, options, listener); + } + + /** + * Stops watching a file for changes. + * @param {string} filePath The path to stop watching + * @param {Function} [listener] Optional listener to remove + */ + unwatchFile(filePath, listener) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].unwatchFile(providerPath, listener); + } + + // ==================== Promise API ==================== + + /** + * Gets the promises API for this VFS instance. + * @returns {object} Promise-based fs methods + */ + get promises() { + if (this[kPromises] === null) { + this[kPromises] = this.#createPromisesAPI(); + } + return this[kPromises]; + } + + /** + * Creates the promises API object for this VFS instance. + * @returns {object} Promise-based fs methods + */ + #createPromisesAPI() { + const provider = this[kProvider]; + + // Use arrow function to capture `this` for private method access + const toProviderPath = (p) => this.#toProviderPath(p); + + return ObjectFreeze({ + async readFile(filePath, options) { + const providerPath = toProviderPath(filePath); + return provider.readFile(providerPath, options); + }, + + async writeFile(filePath, data, options) { + const providerPath = toProviderPath(filePath); + return provider.writeFile(providerPath, data, options); + }, + + async appendFile(filePath, data, options) { + const providerPath = toProviderPath(filePath); + return provider.appendFile(providerPath, data, options); + }, + + async stat(filePath, options) { + const providerPath = toProviderPath(filePath); + return provider.stat(providerPath, options); + }, + + async lstat(filePath, options) { + const providerPath = toProviderPath(filePath); + return provider.lstat(providerPath, options); + }, + + async readdir(dirPath, options) { + const providerPath = toProviderPath(dirPath); + return provider.readdir(providerPath, options); + }, + + async mkdir(dirPath, options) { + const providerPath = toProviderPath(dirPath); + return provider.mkdir(providerPath, options); + }, + + async rmdir(dirPath) { + const providerPath = toProviderPath(dirPath); + return provider.rmdir(providerPath); + }, + + async unlink(filePath) { + const providerPath = toProviderPath(filePath); + return provider.unlink(providerPath); + }, + + async rename(oldPath, newPath) { + const oldProviderPath = toProviderPath(oldPath); + const newProviderPath = toProviderPath(newPath); + return provider.rename(oldProviderPath, newProviderPath); + }, + + async copyFile(src, dest, mode) { + const srcProviderPath = toProviderPath(src); + const destProviderPath = toProviderPath(dest); + return provider.copyFile(srcProviderPath, destProviderPath, mode); + }, + + async realpath(filePath, options) { + const providerPath = toProviderPath(filePath); + return provider.realpath(providerPath, options); + }, + + async readlink(linkPath, options) { + const providerPath = toProviderPath(linkPath); + return provider.readlink(providerPath, options); + }, + + async symlink(target, path, type) { + const providerPath = toProviderPath(path); + return provider.symlink(target, providerPath, type); + }, + + async access(filePath, mode) { + const providerPath = toProviderPath(filePath); + return provider.access(providerPath, mode); + }, + + async rm(filePath, options) { + const recursive = options?.recursive === true; + const force = options?.force === true; + + let stats; + try { + stats = await provider.lstat(toProviderPath(filePath)); + } catch (err) { + if (force && err?.code === 'ENOENT') return; + throw err; + } + + // Symlinks should be unlinked directly, never recursed into + if (stats.isSymbolicLink()) { + await provider.unlink(toProviderPath(filePath)); + return; + } + + if (stats.isDirectory()) { + if (!recursive) { + throw createEISDIR('rm', filePath); + } + const entries = await provider.readdir(toProviderPath(filePath)); + for (let i = 0; i < entries.length; i++) { + await this.rm(joinPath(filePath, entries[i]), options); + } + await provider.rmdir(toProviderPath(filePath)); + } else { + await provider.unlink(toProviderPath(filePath)); + } + }, + + async truncate(filePath, len = 0) { + const providerPath = toProviderPath(filePath); + const handle = await provider.open(providerPath, 'r+'); + try { + await handle.truncate(len); + } finally { + await handle.close(); + } + }, + + async link(existingPath, newPath) { + const existingProviderPath = toProviderPath(existingPath); + const newProviderPath = toProviderPath(newPath); + return provider.link(existingProviderPath, newProviderPath); + }, + + 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; + await provider.mkdir(dirPath); + return dirPath; + }, + + async chmod(filePath, mode) { + const providerPath = toProviderPath(filePath); + provider.chmodSync(providerPath, mode); + }, + + async chown(filePath, uid, gid) { + const providerPath = toProviderPath(filePath); + provider.chownSync(providerPath, uid, gid); + }, + + async lchown(filePath, uid, gid) { + const providerPath = toProviderPath(filePath); + provider.chownSync(providerPath, uid, gid); + }, + + async utimes(filePath, atime, mtime) { + const providerPath = toProviderPath(filePath); + provider.utimesSync(providerPath, atime, mtime); + }, + + async lutimes(filePath, atime, mtime) { + const providerPath = toProviderPath(filePath); + provider.lutimesSync(providerPath, atime, mtime); + }, + + async open(filePath, flags, mode) { + const providerPath = toProviderPath(filePath); + const handle = provider.openSync(providerPath, flags, mode); + return openVirtualFd(handle); + }, + + async lchmod(filePath, mode) { + const providerPath = toProviderPath(filePath); + provider.chmodSync(providerPath, mode); + }, + + watch(filePath, options) { + const providerPath = toProviderPath(filePath); + return provider.watchAsync(providerPath, options); + }, + }); + } +} + +module.exports = { + VirtualFileSystem, +}; diff --git a/lib/internal/vfs/provider.js b/lib/internal/vfs/provider.js new file mode 100644 index 00000000000000..32c238a23fe510 --- /dev/null +++ b/lib/internal/vfs/provider.js @@ -0,0 +1,618 @@ +'use strict'; + +const { + ERR_METHOD_NOT_IMPLEMENTED, +} = require('internal/errors').codes; + +const { + createEROFS, + createEEXIST, + createEACCES, +} = require('internal/vfs/errors'); + +const { + fs: { + R_OK, + W_OK, + X_OK, + COPYFILE_EXCL, + }, +} = internalBinding('constants'); + +/** + * Base class for VFS providers. + * Providers implement the essential primitives that the VFS delegates to. + * + * Implementations must override the essential primitives (open, stat, readdir, etc.) + * Default implementations for derived methods (readFile, writeFile, etc.) are provided. + */ +class VirtualProvider { + // === CAPABILITY FLAGS === + + /** + * Returns true if this provider is read-only. + * @returns {boolean} + */ + get readonly() { + return false; + } + + /** + * Returns true if this provider supports symbolic links. + * @returns {boolean} + */ + get supportsSymlinks() { + return false; + } + + /** + * Returns true if this provider supports file watching. + * @returns {boolean} + */ + get supportsWatch() { + return false; + } + + // === ESSENTIAL PRIMITIVES (must be implemented by subclasses) === + + /** + * Opens a file and returns a file handle. + * @param {string} path The file path (relative to provider root) + * @param {string} flags The open flags ('r', 'r+', 'w', 'w+', 'a', 'a+') + * @param {number} [mode] The file mode (for creating files) + * @returns {Promise} + */ + async open(path, flags, mode) { + throw new ERR_METHOD_NOT_IMPLEMENTED('open'); + } + + /** + * Opens a file synchronously and returns a file handle. + * @param {string} path The file path (relative to provider root) + * @param {string} flags The open flags ('r', 'r+', 'w', 'w+', 'a', 'a+') + * @param {number} [mode] The file mode (for creating files) + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + openSync(path, flags, mode) { + throw new ERR_METHOD_NOT_IMPLEMENTED('openSync'); + } + + /** + * Gets stats for a path. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('stat'); + } + + /** + * Gets stats for a path synchronously. + * @param {string} path The path to stat + * @param {object} [options] Options + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + statSync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('statSync'); + } + + /** + * Gets stats for a path without following symlinks. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Promise} + */ + async lstat(path, options) { + // Default: same as stat (for providers that don't support symlinks) + return this.stat(path, options); + } + + /** + * Gets stats for a path synchronously without following symlinks. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + lstatSync(path, options) { + // Default: same as statSync (for providers that don't support symlinks) + return this.statSync(path, options); + } + + /** + * Reads directory contents. + * @param {string} path The directory path + * @param {object} [options] Options + * @returns {Promise} + */ + async readdir(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readdir'); + } + + /** + * Reads directory contents synchronously. + * @param {string} path The directory path + * @param {object} [options] Options + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + readdirSync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readdirSync'); + } + + /** + * Creates a directory. + * @param {string} path The directory path + * @param {object} [options] Options + * @returns {Promise} + */ + async mkdir(path, options) { + if (this.readonly) { + throw createEROFS('mkdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('mkdir'); + } + + /** + * Creates a directory synchronously. + * @param {string} path The directory path + * @param {object} [options] Options + */ + mkdirSync(path, options) { + if (this.readonly) { + throw createEROFS('mkdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('mkdirSync'); + } + + /** + * Removes a directory. + * @param {string} path The directory path + * @returns {Promise} + */ + async rmdir(path) { + if (this.readonly) { + throw createEROFS('rmdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('rmdir'); + } + + /** + * Removes a directory synchronously. + * @param {string} path The directory path + */ + rmdirSync(path) { + if (this.readonly) { + throw createEROFS('rmdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('rmdirSync'); + } + + /** + * Removes a file. + * @param {string} path The file path + * @returns {Promise} + */ + async unlink(path) { + if (this.readonly) { + throw createEROFS('unlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('unlink'); + } + + /** + * Removes a file synchronously. + * @param {string} path The file path + */ + unlinkSync(path) { + if (this.readonly) { + throw createEROFS('unlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('unlinkSync'); + } + + /** + * Renames a file or directory. + * @param {string} oldPath The old path + * @param {string} newPath The new path + * @returns {Promise} + */ + async rename(oldPath, newPath) { + if (this.readonly) { + throw createEROFS('rename', oldPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('rename'); + } + + /** + * Renames a file or directory synchronously. + * @param {string} oldPath The old path + * @param {string} newPath The new path + */ + renameSync(oldPath, newPath) { + if (this.readonly) { + throw createEROFS('rename', oldPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('renameSync'); + } + + // === DEFAULT IMPLEMENTATIONS (built on primitives) === + + /** + * Reads a file. + * @param {string} path The file path + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(path, options) { + const flag = (typeof options === 'object' && options !== null) ? + (options.flag ?? 'r') : 'r'; + const handle = await this.open(path, flag); + try { + return await handle.readFile(options); + } finally { + await handle.close(); + } + } + + /** + * Reads a file synchronously. + * @param {string} path The file path + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(path, options) { + const flag = (typeof options === 'object' && options !== null) ? + (options.flag ?? 'r') : 'r'; + const handle = this.openSync(path, flag); + try { + return handle.readFileSync(options); + } finally { + handle.closeSync(); + } + } + + /** + * Writes a file. + * @param {string} path The file path + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + * @returns {Promise} + */ + async writeFile(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const flag = options?.flag ?? 'w'; + const handle = await this.open(path, flag, options?.mode); + try { + await handle.writeFile(data, options); + } finally { + await handle.close(); + } + } + + /** + * Writes a file synchronously. + * @param {string} path The file path + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const flag = options?.flag ?? 'w'; + const handle = this.openSync(path, flag, options?.mode); + try { + handle.writeFileSync(data, options); + } finally { + handle.closeSync(); + } + } + + /** + * Appends to a file. + * @param {string} path The file path + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + * @returns {Promise} + */ + async appendFile(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const flag = options?.flag ?? 'a'; + const handle = await this.open(path, flag, options?.mode); + try { + await handle.writeFile(data, options); + } finally { + await handle.close(); + } + } + + /** + * Appends to a file synchronously. + * @param {string} path The file path + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + */ + appendFileSync(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const flag = options?.flag ?? 'a'; + const handle = this.openSync(path, flag, options?.mode); + try { + handle.writeFileSync(data, options); + } finally { + handle.closeSync(); + } + } + + /** + * Checks if a path exists. + * @param {string} path The path to check + * @returns {Promise} + */ + async exists(path) { + try { + await this.stat(path); + return true; + } catch { + return false; + } + } + + /** + * Checks if a path exists synchronously. + * @param {string} path The path to check + * @returns {boolean} + */ + existsSync(path) { + try { + this.statSync(path); + return true; + } catch { + return false; + } + } + + /** + * Copies a file. + * @param {string} src Source path + * @param {string} dest Destination path + * @param {number} [mode] Copy mode flags + * @returns {Promise} + */ + async copyFile(src, dest, mode) { + if (this.readonly) { + throw createEROFS('copyfile', dest); + } + if ((mode & COPYFILE_EXCL) !== 0) { + if (await this.exists(dest)) { + throw createEEXIST('copyfile', dest); + } + } + const content = await this.readFile(src); + await this.writeFile(dest, content); + } + + /** + * Copies a file synchronously. + * @param {string} src Source path + * @param {string} dest Destination path + * @param {number} [mode] Copy mode flags + */ + copyFileSync(src, dest, mode) { + if (this.readonly) { + throw createEROFS('copyfile', dest); + } + if ((mode & COPYFILE_EXCL) !== 0) { + if (this.existsSync(dest)) { + throw createEEXIST('copyfile', dest); + } + } + const content = this.readFileSync(src); + this.writeFileSync(dest, content); + } + + /** + * Gets the real path by resolving symlinks. + * @param {string} path The path + * @param {object} [options] Options + * @returns {Promise} + */ + async realpath(path, options) { + // Default: return the path as-is (for providers without symlinks) + // First verify the path exists + await this.stat(path); + return path; + } + + /** + * Gets the real path synchronously. + * @param {string} path The path + * @param {object} [options] Options + * @returns {string} + */ + realpathSync(path, options) { + // Default: return the path as-is (for providers without symlinks) + // First verify the path exists + this.statSync(path); + return path; + } + + /** + * Checks file accessibility. + * @param {string} path The path to check + * @param {number} [mode] Access mode + * @returns {Promise} + */ + async access(path, mode) { + const stats = await this.stat(path); + this.#checkAccessMode(path, stats, mode); + } + + /** + * Checks file accessibility synchronously. + * @param {string} path The path to check + * @param {number} [mode] Access mode + */ + accessSync(path, mode) { + const stats = this.statSync(path); + this.#checkAccessMode(path, stats, mode); + } + + /** + * Checks access mode bits against file stats. + * @param {string} path The path (for error messages) + * @param {Stats} stats The file stats + * @param {number} mode The requested access mode + */ + #checkAccessMode(path, stats, mode) { + if (mode == null || mode === 0) return; // F_OK = 0, existence-only check + + const fileMode = stats.mode & 0o777; // Permission bits + // Check owner permissions (simplified: treat VFS user as owner) + if ((mode & R_OK) !== 0 && (fileMode & 0o400) === 0) { + throw createEACCES('access', path); + } + if ((mode & W_OK) !== 0 && (fileMode & 0o200) === 0) { + throw createEACCES('access', path); + } + if ((mode & X_OK) !== 0 && (fileMode & 0o100) === 0) { + throw createEACCES('access', path); + } + } + + // === HARD LINK OPERATIONS (optional) === + + /** + * Creates a hard link. + * @param {string} existingPath The existing file path + * @param {string} newPath The new link path + * @returns {Promise} + */ + async link(existingPath, newPath) { + if (this.readonly) { + throw createEROFS('link', newPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('link'); + } + + /** + * Creates a hard link synchronously. + * @param {string} existingPath The existing file path + * @param {string} newPath The new link path + */ + linkSync(existingPath, newPath) { + if (this.readonly) { + throw createEROFS('link', newPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('linkSync'); + } + + // === SYMLINK OPERATIONS (optional, throw ENOENT by default) === + + /** + * Reads the target of a symbolic link. + * @param {string} path The symlink path + * @param {object} [options] Options + * @returns {Promise} + */ + async readlink(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readlink'); + } + + /** + * Reads the target of a symbolic link synchronously. + * @param {string} path The symlink path + * @param {object} [options] Options + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + readlinkSync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readlinkSync'); + } + + /** + * Creates a symbolic link. + * @param {string} target The symlink target + * @param {string} path The symlink path + * @param {string} [type] The symlink type (file, dir, junction) + * @returns {Promise} + */ + async symlink(target, path, type) { + if (this.readonly) { + throw createEROFS('symlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('symlink'); + } + + /** + * Creates a symbolic link synchronously. + * @param {string} target The symlink target + * @param {string} path The symlink path + * @param {string} [type] The symlink type (file, dir, junction) + */ + symlinkSync(target, path, type) { + if (this.readonly) { + throw createEROFS('symlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('symlinkSync'); + } + + // === WATCH OPERATIONS (optional, polling-based) === + + /** + * Watches a file or directory for changes. + * Returns an EventEmitter-like object that emits 'change' and 'close' events. + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @param {number} [options.interval] Polling interval in ms (default: 100) + * @param {boolean} [options.recursive] Watch subdirectories (default: false) + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass + */ + watch(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('watch'); + } + + /** + * Watches a file or directory for changes (async iterable version). + * Used by fs.promises.watch(). + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @param {number} [options.interval] Polling interval in ms (default: 100) + * @param {boolean} [options.recursive] Watch subdirectories (default: false) + * @param {AbortSignal} [options.signal] AbortSignal for cancellation + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass + */ + watchAsync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('watchAsync'); + } + + /** + * Watches a file for changes using stat polling. + * Returns a StatWatcher-like object that emits 'change' events with stats. + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @param {number} [options.interval] Polling interval in ms (default: 5007) + * @param {boolean} [options.persistent] Whether the watcher should prevent exit + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not overridden by subclass + */ + watchFile(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('watchFile'); + } + + /** + * Stops watching a file for changes. + * @param {string} path The path to stop watching + * @param {Function} [listener] Optional listener to remove + */ + unwatchFile(path, listener) { + throw new ERR_METHOD_NOT_IMPLEMENTED('unwatchFile'); + } +} + +module.exports = { + VirtualProvider, +}; diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js new file mode 100644 index 00000000000000..155aa6cf53812b --- /dev/null +++ b/lib/internal/vfs/providers/memory.js @@ -0,0 +1,1024 @@ +'use strict'; + +const { + ArrayFrom, + ArrayPrototypePush, + DateNow, + SafeMap, + StringPrototypeReplaceAll, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { isPromise } = require('util/types'); +const { posix: pathPosix } = require('path'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { MemoryFileHandle } = require('internal/vfs/file_handle'); +const { + VFSWatcher, + VFSStatWatcher, + VFSWatchAsyncIterable, +} = require('internal/vfs/watcher'); +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { + createENOENT, + createENOTDIR, + createENOTEMPTY, + createEISDIR, + createEEXIST, + createEINVAL, + createELOOP, + createEROFS, +} = require('internal/vfs/errors'); +const { + createFileStats, + createDirectoryStats, + createSymlinkStats, +} = require('internal/vfs/stats'); +const { Dirent } = require('internal/fs/utils'); +const { kEmptyObject } = require('internal/util'); +const { + fs: { + O_APPEND, + O_CREAT, + O_EXCL, + O_RDWR, + O_TRUNC, + O_WRONLY, + UV_DIRENT_FILE, + UV_DIRENT_DIR, + UV_DIRENT_LINK, + }, +} = internalBinding('constants'); + +/** + * Converts numeric flags to a string representation. + * If already a string, returns as-is. + * @param {string|number} flags The flags to normalize + * @returns {string} Normalized string flags + */ +function normalizeFlags(flags) { + if (typeof flags === 'string') return flags; + if (typeof flags !== 'number') return 'r'; + + const rdwr = (flags & O_RDWR) !== 0; + const append = (flags & O_APPEND) !== 0; + const excl = (flags & O_EXCL) !== 0; + const write = (flags & O_WRONLY) !== 0 || + (flags & O_CREAT) !== 0 || + (flags & O_TRUNC) !== 0; + + if (append) { + return 'a' + (excl ? 'x' : '') + (rdwr ? '+' : ''); + } + if (write) { + return 'w' + (excl ? 'x' : '') + (rdwr ? '+' : ''); + } + if (rdwr) return 'r+'; + return 'r'; +} + +/** + * Converts a time argument (Date, number, or string) to milliseconds. + * Numbers are treated as seconds (matching Node.js utimes convention). + * @param {Date|number|string} time The time value + * @returns {number} Milliseconds since epoch + */ +function toMs(time) { + if (typeof time === 'number') return time * 1000; + if (typeof time === 'string') return DateNow(); // Fallback for string timestamps + if (typeof time === 'object' && time !== null) return +time; + return time; +} + +// Private symbols +const kRoot = Symbol('kRoot'); +const kReadonly = Symbol('kReadonly'); +const kStatWatchers = Symbol('kStatWatchers'); + +// Entry types +const TYPE_FILE = 0; +const TYPE_DIR = 1; +const TYPE_SYMLINK = 2; + +// Maximum symlink resolution depth +const kMaxSymlinkDepth = 40; + +/** + * Internal entry representation for MemoryProvider. + */ +class MemoryEntry { + constructor(type, options = kEmptyObject) { + this.type = type; + this.mode = options.mode ?? (type === TYPE_DIR ? 0o755 : 0o644); + this.content = null; // For files - static Buffer content + this.contentProvider = null; // For files - dynamic content function + this.target = null; // For symlinks + this.children = null; // For directories + this.populate = null; // For directories - lazy population callback + this.populated = true; // For directories - has populate been called? + this.nlink = 1; + this.uid = 0; + this.gid = 0; + const now = DateNow(); + this.atime = now; + this.mtime = now; + this.ctime = now; + this.birthtime = now; + } + + /** + * Gets the file content synchronously. + * Throws if the content provider returns a Promise. + * @returns {Buffer} The file content + */ + getContentSync() { + if (this.contentProvider !== null) { + const result = this.contentProvider(); + if (isPromise(result)) { + // It's a Promise - can't use sync API + throw new ERR_INVALID_STATE('cannot use sync API with async content provider'); + } + return typeof result === 'string' ? Buffer.from(result) : result; + } + return this.content; + } + + /** + * Gets the file content asynchronously. + * @returns {Promise} The file content + */ + async getContentAsync() { + if (this.contentProvider !== null) { + const result = await this.contentProvider(); + return typeof result === 'string' ? Buffer.from(result) : result; + } + return this.content; + } + + /** + * Returns true if this file has a dynamic content provider. + * @returns {boolean} + */ + isDynamic() { + return this.contentProvider !== null; + } + + isFile() { + return this.type === TYPE_FILE; + } + + isDirectory() { + return this.type === TYPE_DIR; + } + + isSymbolicLink() { + return this.type === TYPE_SYMLINK; + } +} + +/** + * In-memory filesystem provider. + * Supports full read/write operations. + */ +class MemoryProvider extends VirtualProvider { + constructor() { + super(); + // Root directory + this[kRoot] = new MemoryEntry(TYPE_DIR); + this[kRoot].children = new SafeMap(); + this[kReadonly] = false; + // Map of path -> VFSStatWatcher for watchFile + this[kStatWatchers] = new SafeMap(); + } + + get readonly() { + return this[kReadonly]; + } + + get supportsWatch() { + return true; + } + + /** + * Sets the provider to read-only mode. + * Once set to read-only, the provider cannot be changed back to writable. + * This is useful for finalizing a VFS after initial population. + */ + setReadOnly() { + this[kReadonly] = true; + } + + get supportsSymlinks() { + return true; + } + + /** + * Normalizes a path to use forward slashes, removes trailing slash, + * and resolves . and .. components. + * @param {string} path The path to normalize + * @returns {string} Normalized path + */ + #normalizePath(path) { + // Convert backslashes to forward slashes + let normalized = StringPrototypeReplaceAll(path, '\\', '/'); + // Ensure absolute path + if (normalized[0] !== '/') { + normalized = '/' + normalized; + } + // Use path.posix.normalize to resolve . and .. + return pathPosix.normalize(normalized); + } + + /** + * Splits a path into segments. + * @param {string} path Normalized path + * @returns {string[]} Path segments + */ + #splitPath(path) { + if (path === '/') { + return []; + } + return path.slice(1).split('/'); + } + + + /** + * Resolves a symlink target to an absolute path. + * @param {string} symlinkPath The path of the symlink + * @param {string} target The symlink target + * @returns {string} Resolved absolute path + */ + #resolveSymlinkTarget(symlinkPath, target) { + if (target.startsWith('/')) { + return this.#normalizePath(target); + } + // Relative target: resolve against symlink's parent directory + const parentPath = pathPosix.dirname(symlinkPath); + return this.#normalizePath(pathPosix.join(parentPath, target)); + } + + /** + * Looks up an entry by path, optionally following symlinks. + * @param {string} path The path to look up + * @param {boolean} followSymlinks Whether to follow symlinks + * @param {number} depth Current symlink resolution depth + * @returns {{ entry: MemoryEntry|null, resolvedPath: string|null, eloop?: boolean }} + */ + #lookupEntry(path, followSymlinks = true, depth = 0) { + const normalized = this.#normalizePath(path); + + if (normalized === '/') { + return { entry: this[kRoot], resolvedPath: '/' }; + } + + const segments = this.#splitPath(normalized); + let current = this[kRoot]; + let currentPath = '/'; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Always follow symlinks for intermediate path components + if (current.isSymbolicLink()) { + if (depth >= kMaxSymlinkDepth) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const targetPath = this.#resolveSymlinkTarget(currentPath, current.target); + const result = this.#lookupEntry(targetPath, true, depth + 1); + if (result.eloop) { + return result; + } + if (!result.entry) { + return { entry: null, resolvedPath: null }; + } + current = result.entry; + currentPath = result.resolvedPath; + } + + if (!current.isDirectory()) { + return { entry: null, resolvedPath: null }; + } + + // Ensure directory is populated before accessing children + this.#ensurePopulated(current, currentPath); + + const entry = current.children.get(segment); + if (!entry) { + return { entry: null, resolvedPath: null }; + } + + currentPath = pathPosix.join(currentPath, segment); + current = entry; + } + + // Follow symlink at the end if requested + if (current.isSymbolicLink() && followSymlinks) { + if (depth >= kMaxSymlinkDepth) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const targetPath = this.#resolveSymlinkTarget(currentPath, current.target); + return this.#lookupEntry(targetPath, true, depth + 1); + } + + return { entry: current, resolvedPath: currentPath }; + } + + /** + * Gets an entry by path, throwing if not found. + * @param {string} path The path + * @param {string} syscall The syscall name for error + * @param {boolean} followSymlinks Whether to follow symlinks + * @returns {MemoryEntry} + */ + #getEntry(path, syscall, followSymlinks = true) { + const result = this.#lookupEntry(path, followSymlinks); + if (result.eloop) { + throw createELOOP(syscall, path); + } + if (!result.entry) { + throw createENOENT(syscall, path); + } + return result.entry; + } + + /** + * Ensures parent directories exist, optionally creating them. + * @param {string} path The full path + * @param {boolean} create Whether to create missing directories + * @param {string} syscall The syscall name for errors + * @returns {MemoryEntry} The parent directory entry + */ + #ensureParent(path, create, syscall) { + if (path === '/') { + return this[kRoot]; + } + const parentPath = pathPosix.dirname(path); + + const segments = this.#splitPath(parentPath); + let current = this[kRoot]; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Follow symlinks in parent path + if (current.isSymbolicLink()) { + const currentPath = pathPosix.join('/', ...segments.slice(0, i)); + const targetPath = this.#resolveSymlinkTarget(currentPath, current.target); + const result = this.#lookupEntry(targetPath, true, 0); + if (!result.entry) { + throw createENOENT(syscall, path); + } + current = result.entry; + } + + if (!current.isDirectory()) { + throw createENOTDIR(syscall, path); + } + + // Ensure directory is populated before accessing children + const currentPath = pathPosix.join('/', ...segments.slice(0, i)); + this.#ensurePopulated(current, currentPath); + + let entry = current.children.get(segment); + if (!entry) { + if (create) { + entry = new MemoryEntry(TYPE_DIR); + entry.children = new SafeMap(); + current.children.set(segment, entry); + } else { + throw createENOENT(syscall, path); + } + } + current = entry; + } + + // Follow symlinks on the final parent entry + if (current.isSymbolicLink()) { + const targetPath = this.#resolveSymlinkTarget(parentPath, current.target); + const result = this.#lookupEntry(targetPath, true, 0); + if (!result.entry) { + throw createENOENT(syscall, path); + } + current = result.entry; + } + + if (!current.isDirectory()) { + throw createENOTDIR(syscall, path); + } + + // Ensure final directory is populated + this.#ensurePopulated(current, parentPath); + + return current; + } + + /** + * Creates stats for an entry. + * @param {MemoryEntry} entry The entry + * @param {number} [size] Override size for files + * @returns {Stats} + */ + #createStats(entry, size, bigint) { + const options = { + mode: entry.mode, + nlink: entry.nlink, + uid: entry.uid, + gid: entry.gid, + atimeMs: entry.atime, + mtimeMs: entry.mtime, + ctimeMs: entry.ctime, + birthtimeMs: entry.birthtime, + bigint, + }; + + if (entry.isFile()) { + let fileSize = size; + if (fileSize === undefined) { + fileSize = entry.isDynamic() ? + entry.getContentSync().length : + entry.content.length; + } + return createFileStats(fileSize, options); + } else if (entry.isDirectory()) { + return createDirectoryStats(options); + } else if (entry.isSymbolicLink()) { + return createSymlinkStats(entry.target.length, options); + } + + throw new ERR_INVALID_STATE('Unknown entry type'); + } + + /** + * Ensures a directory is populated by calling its populate callback if needed. + * @param {MemoryEntry} entry The directory entry + * @param {string} path The directory path (for error messages and scoped VFS) + */ + #ensurePopulated(entry, path) { + if (entry.isDirectory() && !entry.populated && entry.populate) { + // Create a scoped VFS for the populate callback + const scopedVfs = { + addFile: (name, content, opts) => { + const fileEntry = new MemoryEntry(TYPE_FILE, opts); + if (typeof content === 'function') { + fileEntry.content = Buffer.alloc(0); + fileEntry.contentProvider = content; + } else { + fileEntry.content = typeof content === 'string' ? Buffer.from(content) : content; + } + entry.children.set(name, fileEntry); + }, + addDirectory: (name, populate, opts) => { + const dirEntry = new MemoryEntry(TYPE_DIR, opts); + dirEntry.children = new SafeMap(); + if (typeof populate === 'function') { + dirEntry.populate = populate; + dirEntry.populated = false; + } + entry.children.set(name, dirEntry); + }, + addSymlink: (name, target, opts) => { + const symlinkEntry = new MemoryEntry(TYPE_SYMLINK, opts); + symlinkEntry.target = target; + entry.children.set(name, symlinkEntry); + }, + }; + entry.populate(scopedVfs); + entry.populated = true; + } + } + + openSync(path, flags, mode) { + const normalized = this.#normalizePath(path); + + // Normalize numeric flags to string + flags = normalizeFlags(flags); + + // Handle create and exclusive modes + const isCreate = flags === 'w' || flags === 'w+' || + flags === 'a' || flags === 'a+' || + flags === 'wx' || flags === 'wx+' || + flags === 'ax' || flags === 'ax+'; + const isExclusive = flags === 'wx' || flags === 'wx+' || + flags === 'ax' || flags === 'ax+'; + const isWritable = flags !== 'r'; + + // Check readonly for any writable mode + if (this.readonly && isWritable) { + throw createEROFS('open', path); + } + + let entry; + try { + entry = this.#getEntry(normalized, 'open'); + // Exclusive flag: file must not exist + if (isExclusive) { + throw createEEXIST('open', path); + } + } catch (err) { + if (err.code !== 'ENOENT' || !isCreate) throw err; + // Create the file + const parent = this.#ensureParent(normalized, false, 'open'); + const name = pathPosix.basename(normalized); + entry = new MemoryEntry(TYPE_FILE, { mode }); + entry.content = Buffer.alloc(0); + parent.children.set(name, entry); + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + if (entry.isDirectory()) { + throw createEISDIR('open', path); + } + + if (entry.isSymbolicLink()) { + // Should have been resolved already, but just in case + throw createEINVAL('open', path); + } + + const getStats = (size) => this.#createStats(entry, size); + return new MemoryFileHandle(normalized, flags, mode ?? entry.mode, entry.content, entry, getStats); + } + + async open(path, flags, mode) { + return this.openSync(path, flags, mode); + } + + statSync(path, options) { + const entry = this.#getEntry(path, 'stat', true); + return this.#createStats(entry, undefined, options?.bigint); + } + + async stat(path, options) { + return this.statSync(path, options); + } + + lstatSync(path, options) { + const entry = this.#getEntry(path, 'lstat', false); + return this.#createStats(entry, undefined, options?.bigint); + } + + async lstat(path, options) { + return this.lstatSync(path, options); + } + + readdirSync(path, options) { + const entry = this.#getEntry(path, 'scandir', true); + if (!entry.isDirectory()) { + throw createENOTDIR('scandir', path); + } + + // Ensure directory is populated (for lazy population) + this.#ensurePopulated(entry, path); + + const normalized = this.#normalizePath(path); + const withFileTypes = options?.withFileTypes === true; + const recursive = options?.recursive === true; + + if (recursive) { + return this.#readdirRecursive(entry, normalized, withFileTypes); + } + + if (withFileTypes) { + const dirents = []; + for (const { 0: name, 1: childEntry } of entry.children) { + let type; + if (childEntry.isSymbolicLink()) { + type = UV_DIRENT_LINK; + } else if (childEntry.isDirectory()) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; + } + ArrayPrototypePush(dirents, new Dirent(name, type, normalized)); + } + return dirents; + } + + return ArrayFrom(entry.children.keys()); + } + + /** + * Recursively reads directory contents. + * @param {MemoryEntry} dirEntry The directory entry + * @param {string} dirPath The normalized directory path + * @param {boolean} withFileTypes Whether to return Dirent objects + * @returns {string[]|Dirent[]} + */ + #readdirRecursive(dirEntry, dirPath, withFileTypes) { + const results = []; + + const walk = (entry, currentPath, relativePath) => { + this.#ensurePopulated(entry, currentPath); + + for (const { 0: name, 1: childEntry } of entry.children) { + const childRelative = relativePath ? + relativePath + '/' + name : name; + + if (withFileTypes) { + let type; + if (childEntry.isSymbolicLink()) { + type = UV_DIRENT_LINK; + } else if (childEntry.isDirectory()) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; + } + ArrayPrototypePush(results, + new Dirent(childRelative, type, dirPath)); + } else { + ArrayPrototypePush(results, childRelative); + } + + // Follow symlinks to directories for recursive traversal + let resolvedChild = childEntry; + if (childEntry.isSymbolicLink()) { + const targetPath = this.#resolveSymlinkTarget( + pathPosix.join(currentPath, name), childEntry.target, + ); + const result = this.#lookupEntry(targetPath, true, 0); + if (result.entry) { + resolvedChild = result.entry; + } + } + if (resolvedChild.isDirectory()) { + const childPath = pathPosix.join(currentPath, name); + walk(resolvedChild, childPath, childRelative); + } + } + }; + + walk(dirEntry, dirPath, ''); + return results; + } + + async readdir(path, options) { + return this.readdirSync(path, options); + } + + mkdirSync(path, options) { + if (this.readonly) { + throw createEROFS('mkdir', path); + } + + const normalized = this.#normalizePath(path); + const recursive = options?.recursive === true; + + // Check if already exists + const existing = this.#lookupEntry(normalized, true); + if (existing.entry) { + if (existing.entry.isDirectory() && recursive) { + // Already exists, that's ok for recursive + return undefined; + } + throw createEEXIST('mkdir', path); + } + + if (recursive) { + // Create all parent directories + const segments = this.#splitPath(normalized); + let current = this[kRoot]; + let currentPath = '/'; + let firstCreated; + + for (const segment of segments) { + currentPath = pathPosix.join(currentPath, segment); + let entry = current.children.get(segment); + if (!entry) { + entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new SafeMap(); + current.children.set(segment, entry); + if (firstCreated === undefined) { + firstCreated = currentPath; + } + } else if (!entry.isDirectory()) { + throw createENOTDIR('mkdir', path); + } + current = entry; + } + return firstCreated; + } + + const parent = this.#ensureParent(normalized, false, 'mkdir'); + const name = pathPosix.basename(normalized); + const entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new SafeMap(); + parent.children.set(name, entry); + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + return undefined; + } + + async mkdir(path, options) { + return this.mkdirSync(path, options); + } + + rmdirSync(path) { + if (this.readonly) { + throw createEROFS('rmdir', path); + } + + const normalized = this.#normalizePath(path); + const entry = this.#getEntry(normalized, 'rmdir', false); + + if (!entry.isDirectory()) { + throw createENOTDIR('rmdir', path); + } + + if (entry.children.size > 0) { + throw createENOTEMPTY('rmdir', path); + } + + const parent = this.#ensureParent(normalized, false, 'rmdir'); + const name = pathPosix.basename(normalized); + parent.children.delete(name); + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + async rmdir(path) { + this.rmdirSync(path); + } + + unlinkSync(path) { + if (this.readonly) { + throw createEROFS('unlink', path); + } + + const normalized = this.#normalizePath(path); + const entry = this.#getEntry(normalized, 'unlink', false); + + if (entry.isDirectory()) { + throw createEISDIR('unlink', path); + } + + const parent = this.#ensureParent(normalized, false, 'unlink'); + const name = pathPosix.basename(normalized); + parent.children.delete(name); + entry.nlink--; + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + async unlink(path) { + this.unlinkSync(path); + } + + renameSync(oldPath, newPath) { + if (this.readonly) { + throw createEROFS('rename', oldPath); + } + + const normalizedOld = this.#normalizePath(oldPath); + const normalizedNew = this.#normalizePath(newPath); + + // Get the entry (without following symlinks for the entry itself) + const entry = this.#getEntry(normalizedOld, 'rename', false); + + // Validate destination parent exists (do not auto-create) + const newParent = this.#ensureParent(normalizedNew, false, 'rename'); + const newName = pathPosix.basename(normalizedNew); + + // Check if destination exists + const existingDest = newParent.children.get(newName); + if (existingDest) { + // Cannot overwrite a directory with a non-directory + if (existingDest.isDirectory() && !entry.isDirectory()) { + throw createEISDIR('rename', newPath); + } + // Cannot overwrite a non-directory with a directory + if (!existingDest.isDirectory() && entry.isDirectory()) { + throw createENOTDIR('rename', newPath); + } + } + + // Remove from old location (after destination validation) + const oldParent = this.#ensureParent(normalizedOld, false, 'rename'); + const oldName = pathPosix.basename(normalizedOld); + oldParent.children.delete(oldName); + + // Add to new location + newParent.children.set(newName, entry); + + const now = DateNow(); + oldParent.mtime = now; + oldParent.ctime = now; + if (newParent !== oldParent) { + newParent.mtime = now; + newParent.ctime = now; + } + } + + async rename(oldPath, newPath) { + this.renameSync(oldPath, newPath); + } + + linkSync(existingPath, newPath) { + if (this.readonly) { + throw createEROFS('link', newPath); + } + + const normalizedExisting = this.#normalizePath(existingPath); + const normalizedNew = this.#normalizePath(newPath); + + const entry = this.#getEntry(normalizedExisting, 'link', true); + if (!entry.isFile()) { + // Hard links to directories are not supported + throw createEINVAL('link', existingPath); + } + + // Check if new path already exists + const existing = this.#lookupEntry(normalizedNew, false); + if (existing.entry) { + throw createEEXIST('link', newPath); + } + + const parent = this.#ensureParent(normalizedNew, false, 'link'); + const name = pathPosix.basename(normalizedNew); + // Hard link: same entry object referenced by both names + parent.children.set(name, entry); + entry.nlink++; + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + async link(existingPath, newPath) { + this.linkSync(existingPath, newPath); + } + + readlinkSync(path, options) { + const normalized = this.#normalizePath(path); + const entry = this.#getEntry(normalized, 'readlink', false); + + if (!entry.isSymbolicLink()) { + throw createEINVAL('readlink', path); + } + + return entry.target; + } + + async readlink(path, options) { + return this.readlinkSync(path, options); + } + + symlinkSync(target, path, type) { + if (this.readonly) { + throw createEROFS('symlink', path); + } + + const normalized = this.#normalizePath(path); + + // Check if already exists + const existing = this.#lookupEntry(normalized, false); + if (existing.entry) { + throw createEEXIST('symlink', path); + } + + const parent = this.#ensureParent(normalized, false, 'symlink'); + const name = pathPosix.basename(normalized); + const entry = new MemoryEntry(TYPE_SYMLINK); + entry.target = target; + parent.children.set(name, entry); + const now = DateNow(); + parent.mtime = now; + parent.ctime = now; + } + + async symlink(target, path, type) { + this.symlinkSync(target, path, type); + } + + realpathSync(path, options) { + const result = this.#lookupEntry(path, true, 0); + if (result.eloop) { + throw createELOOP('realpath', path); + } + if (!result.entry) { + throw createENOENT('realpath', path); + } + return result.resolvedPath; + } + + async realpath(path, options) { + return this.realpathSync(path, options); + } + + // === METADATA OPERATIONS === + + chmodSync(path, mode) { + const entry = this.#getEntry(path, 'chmod', true); + // Preserve file type bits, update permission bits + entry.mode = (entry.mode & ~0o7777) | (mode & 0o7777); + entry.ctime = DateNow(); + } + + chownSync(path, uid, gid) { + const entry = this.#getEntry(path, 'chown', true); + if (uid >= 0) entry.uid = uid; + if (gid >= 0) entry.gid = gid; + entry.ctime = DateNow(); + } + + utimesSync(path, atime, mtime) { + const entry = this.#getEntry(path, 'utime', true); + entry.atime = toMs(atime); + entry.mtime = toMs(mtime); + entry.ctime = DateNow(); + } + + lutimesSync(path, atime, mtime) { + const entry = this.#getEntry(path, 'utime', false); + entry.atime = toMs(atime); + entry.mtime = toMs(mtime); + entry.ctime = DateNow(); + } + + // === WATCH OPERATIONS === + + /** + * Watches a file or directory for changes. + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @returns {VFSWatcher} + */ + watch(path, options) { + const normalized = this.#normalizePath(path); + return new VFSWatcher(this, normalized, options); + } + + /** + * Watches a file or directory for changes (async iterable version). + * Used by fs.promises.watch(). + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @returns {VFSWatchAsyncIterable} + */ + watchAsync(path, options) { + const normalized = this.#normalizePath(path); + return new VFSWatchAsyncIterable(this, normalized, options); + } + + /** + * Watches a file for changes using stat polling. + * @param {string} path The path to watch + * @param {object} [options] Watch options + * @param {Function} [listener] Change listener + * @returns {VFSStatWatcher} + */ + watchFile(path, options, listener) { + const normalized = this.#normalizePath(path); + + // Reuse existing watcher for the same path + let watcher = this[kStatWatchers].get(normalized); + if (!watcher) { + watcher = new VFSStatWatcher(this, normalized, options); + this[kStatWatchers].set(normalized, watcher); + } + + if (listener) { + watcher.addListener('change', listener); + } + + return watcher; + } + + /** + * Stops watching a file for changes. + * @param {string} path The path to stop watching + * @param {Function} [listener] Optional listener to remove + */ + unwatchFile(path, listener) { + const normalized = this.#normalizePath(path); + const watcher = this[kStatWatchers].get(normalized); + + if (!watcher) { + return; + } + + if (listener) { + watcher.removeListener('change', listener); + } else { + // Remove all listeners + watcher.removeAllListeners('change'); + } + + // If no more listeners, stop and remove the watcher + if (watcher.hasNoListeners()) { + watcher.stop(); + this[kStatWatchers].delete(normalized); + } + } +} + +module.exports = { + MemoryProvider, +}; diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js new file mode 100644 index 00000000000000..32d7769859693c --- /dev/null +++ b/lib/internal/vfs/providers/real.js @@ -0,0 +1,492 @@ +'use strict'; + +const { + ObjectDefineProperty, + Promise, + StringPrototypeStartsWith, +} = primordials; + +const fs = require('fs'); +const path = require('path'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { VirtualFileHandle } = require('internal/vfs/file_handle'); +const { + codes: { + ERR_INVALID_ARG_VALUE, + }, +} = require('internal/errors'); +const { + createEACCES, + createEBADF, + createENOENT, +} = require('internal/vfs/errors'); + +/** + * A file handle that wraps a real file descriptor. + */ +class RealFileHandle extends VirtualFileHandle { + #fd; + #realPath; + + #checkClosed(syscall) { + if (this.closed) { + throw createEBADF(syscall); + } + } + + /** + * @param {string} path The VFS path + * @param {string} flags The open flags + * @param {number} mode The file mode + * @param {number} fd The real file descriptor + * @param {string} realPath The real filesystem path + */ + constructor(path, flags, mode, fd, realPath) { + super(path, flags, mode); + this.#fd = fd; + this.#realPath = realPath; + } + + /** + * Gets the real file descriptor. + * @returns {number} + */ + get fd() { + return this.#fd; + } + + readSync(buffer, offset, length, position) { + this.#checkClosed('read'); + return fs.readSync(this.#fd, buffer, offset, length, position); + } + + async read(buffer, offset, length, position) { + this.#checkClosed('read'); + return new Promise((resolve, reject) => { + fs.read(this.#fd, buffer, offset, length, position, (err, bytesRead) => { + if (err) reject(err); + else resolve({ __proto__: null, bytesRead, buffer }); + }); + }); + } + + writeSync(buffer, offset, length, position) { + this.#checkClosed('write'); + return fs.writeSync(this.#fd, buffer, offset, length, position); + } + + async write(buffer, offset, length, position) { + this.#checkClosed('write'); + return new Promise((resolve, reject) => { + fs.write(this.#fd, buffer, offset, length, position, (err, bytesWritten) => { + if (err) reject(err); + else resolve({ __proto__: null, bytesWritten, buffer }); + }); + }); + } + + readFileSync(options) { + this.#checkClosed('read'); + return fs.readFileSync(this.#realPath, options); + } + + async readFile(options) { + this.#checkClosed('read'); + return fs.promises.readFile(this.#realPath, options); + } + + writeFileSync(data, options) { + this.#checkClosed('write'); + fs.writeFileSync(this.#realPath, data, options); + } + + async writeFile(data, options) { + this.#checkClosed('write'); + return fs.promises.writeFile(this.#realPath, data, options); + } + + statSync(options) { + this.#checkClosed('fstat'); + return fs.fstatSync(this.#fd, options); + } + + async stat(options) { + this.#checkClosed('fstat'); + return new Promise((resolve, reject) => { + fs.fstat(this.#fd, options, (err, stats) => { + if (err) reject(err); + else resolve(stats); + }); + }); + } + + truncateSync(len = 0) { + this.#checkClosed('ftruncate'); + fs.ftruncateSync(this.#fd, len); + } + + async truncate(len = 0) { + this.#checkClosed('ftruncate'); + return new Promise((resolve, reject) => { + fs.ftruncate(this.#fd, len, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + closeSync() { + if (!this.closed) { + fs.closeSync(this.#fd); + super.closeSync(); + } + } + + async close() { + if (!this.closed) { + return new Promise((resolve, reject) => { + fs.close(this.#fd, (err) => { + if (err) reject(err); + else { + super.closeSync(); + resolve(); + } + }); + }); + } + } +} + +/** + * A provider that wraps a real filesystem directory. + * Allows mounting a real directory at a different VFS path. + */ +class RealFSProvider extends VirtualProvider { + #rootPath; + + /** + * @param {string} rootPath The real filesystem path to use as root + */ + constructor(rootPath) { + super(); + if (typeof rootPath !== 'string' || rootPath === '') { + throw new ERR_INVALID_ARG_VALUE('rootPath', rootPath, 'must be a non-empty string'); + } + // Resolve to absolute path and normalize + this.#rootPath = path.resolve(rootPath); + ObjectDefineProperty(this, 'readonly', { __proto__: null, value: false }); + ObjectDefineProperty(this, 'supportsSymlinks', { __proto__: null, value: true }); + } + + /** + * Gets the root path of this provider. + * @returns {string} + */ + get rootPath() { + return this.#rootPath; + } + + /** + * Resolves a VFS path to a real filesystem path. + * Ensures the path doesn't escape the root directory. + * @param {string} vfsPath The VFS path (relative to provider root) + * @returns {string} The real filesystem path + * @private + */ + #resolvePath(vfsPath, followSymlinks = true) { + // Normalize the VFS path (remove leading slash, handle . and ..) + let normalized = vfsPath; + if (normalized.startsWith('/')) { + normalized = normalized.slice(1); + } + + // Join with root and resolve + const realPath = path.resolve(this.#rootPath, normalized); + + // Security check: ensure the resolved path is within rootPath + const rootWithSep = this.#rootPath.endsWith(path.sep) ? + this.#rootPath : + this.#rootPath + path.sep; + + if (realPath !== this.#rootPath && !StringPrototypeStartsWith(realPath, rootWithSep)) { + throw createENOENT('open', vfsPath); + } + + // Resolve symlinks to prevent escape via symbolic links + if (followSymlinks) { + try { + const resolved = fs.realpathSync(realPath); + if (resolved !== this.#rootPath && + !StringPrototypeStartsWith(resolved, rootWithSep)) { + throw createENOENT('open', vfsPath); + } + return resolved; + } catch (err) { + if (err?.code !== 'ENOENT') throw err; + // Path doesn't exist yet - verify deepest existing ancestor + this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath); + return realPath; + } + } + + // For lstat/readlink (no final symlink follow), check parent only + this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath); + return realPath; + } + + /** + * Verifies that the deepest existing ancestor of a path is within rootPath. + * @param {string} realPath The real filesystem path + * @param {string} rootWithSep The rootPath with trailing separator + * @param {string} vfsPath The original VFS path (for error messages) + */ + #verifyAncestorInRoot(realPath, rootWithSep, vfsPath) { + let current = path.dirname(realPath); + while (current.length >= this.#rootPath.length) { + try { + const resolved = fs.realpathSync(current); + if (resolved !== this.#rootPath && + !StringPrototypeStartsWith(resolved, rootWithSep)) { + throw createENOENT('open', vfsPath); + } + return; + } catch (err) { + if (err?.code !== 'ENOENT') throw err; + current = path.dirname(current); + } + } + } + + openSync(vfsPath, flags, mode) { + const realPath = this.#resolvePath(vfsPath); + const fd = fs.openSync(realPath, flags, mode); + return new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath); + } + + async open(vfsPath, flags, mode) { + const realPath = this.#resolvePath(vfsPath); + return new Promise((resolve, reject) => { + fs.open(realPath, flags, mode, (err, fd) => { + if (err) reject(err); + else resolve(new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath)); + }); + }); + } + + statSync(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + return fs.statSync(realPath, options); + } + + async stat(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + return fs.promises.stat(realPath, options); + } + + lstatSync(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath, false); + return fs.lstatSync(realPath, options); + } + + async lstat(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath, false); + return fs.promises.lstat(realPath, options); + } + + readdirSync(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + return fs.readdirSync(realPath, options); + } + + async readdir(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + return fs.promises.readdir(realPath, options); + } + + mkdirSync(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + return fs.mkdirSync(realPath, options); + } + + async mkdir(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + return fs.promises.mkdir(realPath, options); + } + + rmdirSync(vfsPath) { + const realPath = this.#resolvePath(vfsPath); + fs.rmdirSync(realPath); + } + + async rmdir(vfsPath) { + const realPath = this.#resolvePath(vfsPath); + return fs.promises.rmdir(realPath); + } + + unlinkSync(vfsPath) { + const realPath = this.#resolvePath(vfsPath); + fs.unlinkSync(realPath); + } + + async unlink(vfsPath) { + const realPath = this.#resolvePath(vfsPath); + return fs.promises.unlink(realPath); + } + + renameSync(oldVfsPath, newVfsPath) { + const oldRealPath = this.#resolvePath(oldVfsPath); + const newRealPath = this.#resolvePath(newVfsPath); + fs.renameSync(oldRealPath, newRealPath); + } + + async rename(oldVfsPath, newVfsPath) { + const oldRealPath = this.#resolvePath(oldVfsPath); + const newRealPath = this.#resolvePath(newVfsPath); + return fs.promises.rename(oldRealPath, newRealPath); + } + + readlinkSync(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath, false); + const target = fs.readlinkSync(realPath, options); + // Translate absolute targets within rootPath to VFS-relative + if (path.isAbsolute(target)) { + const rootWithSep = this.#rootPath + path.sep; + if (target === this.#rootPath) { + return '/'; + } + if (StringPrototypeStartsWith(target, rootWithSep)) { + return '/' + target.slice(rootWithSep.length).replace(/\\/g, '/'); + } + } + return target; + } + + async readlink(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath, false); + const target = await fs.promises.readlink(realPath, options); + // Translate absolute targets within rootPath to VFS-relative + if (path.isAbsolute(target)) { + const rootWithSep = this.#rootPath + path.sep; + if (target === this.#rootPath) { + return '/'; + } + if (StringPrototypeStartsWith(target, rootWithSep)) { + return '/' + target.slice(rootWithSep.length).replace(/\\/g, '/'); + } + } + return target; + } + + symlinkSync(target, vfsPath, type) { + // Validate target resolves within rootPath + if (path.isAbsolute(target)) { + throw createEACCES('symlink', vfsPath); + } + const realPath = this.#resolvePath(vfsPath); + const resolvedTarget = path.resolve(path.dirname(realPath), target); + const rootWithSep = this.#rootPath.endsWith(path.sep) ? + this.#rootPath : this.#rootPath + path.sep; + if (resolvedTarget !== this.#rootPath && + !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) { + throw createEACCES('symlink', vfsPath); + } + fs.symlinkSync(target, realPath, type); + } + + async symlink(target, vfsPath, type) { + // Validate target resolves within rootPath + if (path.isAbsolute(target)) { + throw createEACCES('symlink', vfsPath); + } + const realPath = this.#resolvePath(vfsPath); + const resolvedTarget = path.resolve(path.dirname(realPath), target); + const rootWithSep = this.#rootPath.endsWith(path.sep) ? + this.#rootPath : this.#rootPath + path.sep; + if (resolvedTarget !== this.#rootPath && + !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) { + throw createEACCES('symlink', vfsPath); + } + return fs.promises.symlink(target, realPath, type); + } + + realpathSync(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + const resolved = fs.realpathSync(realPath, options); + // Convert back to VFS path + if (resolved === this.#rootPath) { + return '/'; + } + const rootWithSep = this.#rootPath + path.sep; + if (StringPrototypeStartsWith(resolved, rootWithSep)) { + return '/' + resolved.slice(rootWithSep.length).replace(/\\/g, '/'); + } + // Path escaped root via symlink - deny access + throw createEACCES('realpath', vfsPath); + } + + async realpath(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + const resolved = await fs.promises.realpath(realPath, options); + // Convert back to VFS path + if (resolved === this.#rootPath) { + return '/'; + } + const rootWithSep = this.#rootPath + path.sep; + if (StringPrototypeStartsWith(resolved, rootWithSep)) { + return '/' + resolved.slice(rootWithSep.length).replace(/\\/g, '/'); + } + // Path escaped root via symlink - deny access + throw createEACCES('realpath', vfsPath); + } + + accessSync(vfsPath, mode) { + const realPath = this.#resolvePath(vfsPath); + fs.accessSync(realPath, mode); + } + + async access(vfsPath, mode) { + const realPath = this.#resolvePath(vfsPath); + return fs.promises.access(realPath, mode); + } + + copyFileSync(srcVfsPath, destVfsPath, mode) { + const srcRealPath = this.#resolvePath(srcVfsPath); + const destRealPath = this.#resolvePath(destVfsPath); + fs.copyFileSync(srcRealPath, destRealPath, mode); + } + + async copyFile(srcVfsPath, destVfsPath, mode) { + const srcRealPath = this.#resolvePath(srcVfsPath); + const destRealPath = this.#resolvePath(destVfsPath); + return fs.promises.copyFile(srcRealPath, destRealPath, mode); + } + + get supportsWatch() { + return true; + } + + watch(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + return fs.watch(realPath, options); + } + + watchAsync(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + return fs.promises.watch(realPath, options); + } + + watchFile(vfsPath, options) { + const realPath = this.#resolvePath(vfsPath); + return fs.watchFile(realPath, options, () => {}); + } + + unwatchFile(vfsPath, listener) { + const realPath = this.#resolvePath(vfsPath); + fs.unwatchFile(realPath, listener); + } +} + +module.exports = { + RealFSProvider, + RealFileHandle, +}; diff --git a/lib/internal/vfs/stats.js b/lib/internal/vfs/stats.js new file mode 100644 index 00000000000000..fdec6fe87cad26 --- /dev/null +++ b/lib/internal/vfs/stats.js @@ -0,0 +1,300 @@ +'use strict'; + +const { + BigInt, + BigInt64Array, + DateNow, + Float64Array, + MathCeil, + MathFloor, +} = primordials; + +const { + fs: { + S_IFDIR, + S_IFREG, + S_IFLNK, + }, +} = internalBinding('constants'); + +const { getStatsFromBinding } = require('internal/fs/utils'); + +// Default block size for virtual files (4KB) +const kDefaultBlockSize = 4096; + +// Distinctive device number for VFS files (0xVF5 = 4085) +const kVfsDev = 4085; + +// Incrementing inode counter for unique ino values +let inoCounter = 1; + +// Reusable arrays for creating Stats objects. +// IMPORTANT: Safe only because getStatsFromBinding copies synchronously. +// Do not use in async paths. +// Format: dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, +// atime_sec, atime_nsec, mtime_sec, mtime_nsec, ctime_sec, ctime_nsec, +// birthtime_sec, birthtime_nsec +const statsArray = new Float64Array(18); +const bigintStatsArray = new BigInt64Array(18); + +/** + * Converts milliseconds to seconds and nanoseconds. + * @param {number} ms Milliseconds + * @returns {{ sec: number, nsec: number }} + */ +function msToTimeSpec(ms) { + const sec = MathFloor(ms / 1000); + const nsec = (ms % 1000) * 1_000_000; + return { sec, nsec }; +} + +/** + * Fills the bigint stats array with the given values. + * @returns {Stats} + */ +function fillBigIntStatsArray( + dev, mode, nlink, uid, gid, rdev, blksize, ino, + size, blocks, atime, mtime, ctime, birthtime, +) { + bigintStatsArray[0] = BigInt(dev); + bigintStatsArray[1] = BigInt(mode); + bigintStatsArray[2] = BigInt(nlink); + bigintStatsArray[3] = BigInt(uid); + bigintStatsArray[4] = BigInt(gid); + bigintStatsArray[5] = BigInt(rdev); + bigintStatsArray[6] = BigInt(blksize); + bigintStatsArray[7] = BigInt(ino); + bigintStatsArray[8] = BigInt(size); + bigintStatsArray[9] = BigInt(blocks); + bigintStatsArray[10] = BigInt(atime.sec); + bigintStatsArray[11] = BigInt(atime.nsec); + bigintStatsArray[12] = BigInt(mtime.sec); + bigintStatsArray[13] = BigInt(mtime.nsec); + bigintStatsArray[14] = BigInt(ctime.sec); + bigintStatsArray[15] = BigInt(ctime.nsec); + bigintStatsArray[16] = BigInt(birthtime.sec); + bigintStatsArray[17] = BigInt(birthtime.nsec); + return getStatsFromBinding(bigintStatsArray); +} + +/** + * Creates a Stats object for a virtual file. + * @param {number} size The file size in bytes + * @param {object} [options] Optional stat properties + * @param {number} [options.mode] File mode (default: 0o644) + * @param {number} [options.uid] User ID (default: process.getuid() or 0) + * @param {number} [options.gid] Group ID (default: process.getgid() or 0) + * @param {number} [options.atimeMs] Access time in ms + * @param {number} [options.mtimeMs] Modification time in ms + * @param {number} [options.ctimeMs] Change time in ms + * @param {number} [options.birthtimeMs] Birth time in ms + * @param {boolean} [options.bigint] Return BigIntStats + * @returns {Stats} + */ +function createFileStats(size, options = {}) { + const now = DateNow(); + const mode = (options.mode ?? 0o644) | S_IFREG; + const nlink = options.nlink ?? 1; + const uid = options.uid ?? (process.getuid?.() ?? 0); + const gid = options.gid ?? (process.getgid?.() ?? 0); + const atimeMs = options.atimeMs ?? now; + const mtimeMs = options.mtimeMs ?? now; + const ctimeMs = options.ctimeMs ?? now; + const birthtimeMs = options.birthtimeMs ?? now; + const blocks = MathCeil(size / 512); + const ino = inoCounter++; + + const atime = msToTimeSpec(atimeMs); + const mtime = msToTimeSpec(mtimeMs); + const ctime = msToTimeSpec(ctimeMs); + const birthtime = msToTimeSpec(birthtimeMs); + + if (options.bigint) { + return fillBigIntStatsArray( + kVfsDev, mode, nlink, uid, gid, 0, kDefaultBlockSize, ino, + size, blocks, atime, mtime, ctime, birthtime, + ); + } + + statsArray[0] = kVfsDev; // dev + statsArray[1] = mode; // mode + statsArray[2] = nlink; // nlink + statsArray[3] = uid; // uid + statsArray[4] = gid; // gid + statsArray[5] = 0; // rdev + statsArray[6] = kDefaultBlockSize; // blksize + statsArray[7] = ino; // ino + statsArray[8] = size; // size + statsArray[9] = blocks; // blocks + statsArray[10] = atime.sec; // atime_sec + statsArray[11] = atime.nsec; // atime_nsec + statsArray[12] = mtime.sec; // mtime_sec + statsArray[13] = mtime.nsec; // mtime_nsec + statsArray[14] = ctime.sec; // ctime_sec + statsArray[15] = ctime.nsec; // ctime_nsec + statsArray[16] = birthtime.sec; // birthtime_sec + statsArray[17] = birthtime.nsec; // birthtime_nsec + + return getStatsFromBinding(statsArray); +} + +/** + * Creates a Stats object for a virtual directory. + * @param {object} [options] Optional stat properties + * @param {number} [options.mode] Directory mode (default: 0o755) + * @param {number} [options.uid] User ID (default: process.getuid() or 0) + * @param {number} [options.gid] Group ID (default: process.getgid() or 0) + * @param {number} [options.atimeMs] Access time in ms + * @param {number} [options.mtimeMs] Modification time in ms + * @param {number} [options.ctimeMs] Change time in ms + * @param {number} [options.birthtimeMs] Birth time in ms + * @returns {Stats} + */ +function createDirectoryStats(options = {}) { + const now = DateNow(); + const mode = (options.mode ?? 0o755) | S_IFDIR; + const uid = options.uid ?? (process.getuid?.() ?? 0); + const gid = options.gid ?? (process.getgid?.() ?? 0); + const atimeMs = options.atimeMs ?? now; + const mtimeMs = options.mtimeMs ?? now; + const ctimeMs = options.ctimeMs ?? now; + const birthtimeMs = options.birthtimeMs ?? now; + const ino = inoCounter++; + + const atime = msToTimeSpec(atimeMs); + const mtime = msToTimeSpec(mtimeMs); + const ctime = msToTimeSpec(ctimeMs); + const birthtime = msToTimeSpec(birthtimeMs); + + if (options.bigint) { + return fillBigIntStatsArray( + kVfsDev, mode, 1, uid, gid, 0, kDefaultBlockSize, ino, + kDefaultBlockSize, 8, atime, mtime, ctime, birthtime, + ); + } + + statsArray[0] = kVfsDev; // dev + statsArray[1] = mode; // mode + statsArray[2] = 1; // nlink + statsArray[3] = uid; // uid + statsArray[4] = gid; // gid + statsArray[5] = 0; // rdev + statsArray[6] = kDefaultBlockSize; // blksize + statsArray[7] = ino; // ino + statsArray[8] = kDefaultBlockSize; // size (directory size) + statsArray[9] = 8; // blocks + statsArray[10] = atime.sec; // atime_sec + statsArray[11] = atime.nsec; // atime_nsec + statsArray[12] = mtime.sec; // mtime_sec + statsArray[13] = mtime.nsec; // mtime_nsec + statsArray[14] = ctime.sec; // ctime_sec + statsArray[15] = ctime.nsec; // ctime_nsec + statsArray[16] = birthtime.sec; // birthtime_sec + statsArray[17] = birthtime.nsec; // birthtime_nsec + + return getStatsFromBinding(statsArray); +} + +/** + * Creates a Stats object for a virtual symbolic link. + * @param {number} size The symlink size (length of target path) + * @param {object} [options] Optional stat properties + * @param {number} [options.mode] Symlink mode (default: 0o777) + * @param {number} [options.uid] User ID (default: process.getuid() or 0) + * @param {number} [options.gid] Group ID (default: process.getgid() or 0) + * @param {number} [options.atimeMs] Access time in ms + * @param {number} [options.mtimeMs] Modification time in ms + * @param {number} [options.ctimeMs] Change time in ms + * @param {number} [options.birthtimeMs] Birth time in ms + * @returns {Stats} + */ +function createSymlinkStats(size, options = {}) { + const now = DateNow(); + const mode = (options.mode ?? 0o777) | S_IFLNK; + const uid = options.uid ?? (process.getuid?.() ?? 0); + const gid = options.gid ?? (process.getgid?.() ?? 0); + const atimeMs = options.atimeMs ?? now; + const mtimeMs = options.mtimeMs ?? now; + const ctimeMs = options.ctimeMs ?? now; + const birthtimeMs = options.birthtimeMs ?? now; + const blocks = MathCeil(size / 512); + const ino = inoCounter++; + + const atime = msToTimeSpec(atimeMs); + const mtime = msToTimeSpec(mtimeMs); + const ctime = msToTimeSpec(ctimeMs); + const birthtime = msToTimeSpec(birthtimeMs); + + if (options.bigint) { + return fillBigIntStatsArray( + kVfsDev, mode, 1, uid, gid, 0, kDefaultBlockSize, ino, + size, blocks, atime, mtime, ctime, birthtime, + ); + } + + statsArray[0] = kVfsDev; // dev + statsArray[1] = mode; // mode + statsArray[2] = 1; // nlink + statsArray[3] = uid; // uid + statsArray[4] = gid; // gid + statsArray[5] = 0; // rdev + statsArray[6] = kDefaultBlockSize; // blksize + statsArray[7] = ino; // ino + statsArray[8] = size; // size + statsArray[9] = blocks; // blocks + statsArray[10] = atime.sec; // atime_sec + statsArray[11] = atime.nsec; // atime_nsec + statsArray[12] = mtime.sec; // mtime_sec + statsArray[13] = mtime.nsec; // mtime_nsec + statsArray[14] = ctime.sec; // ctime_sec + statsArray[15] = ctime.nsec; // ctime_nsec + statsArray[16] = birthtime.sec; // birthtime_sec + statsArray[17] = birthtime.nsec; // birthtime_nsec + + return getStatsFromBinding(statsArray); +} + +/** + * Creates a zeroed Stats object for non-existent files. + * All fields are zero, including mode (no S_IFREG bit set). + * This matches Node.js fs.watchFile() behavior for missing files. + * @returns {Stats} + */ +function createZeroStats(options) { + const zero = { sec: 0, nsec: 0 }; + + if (options?.bigint) { + return fillBigIntStatsArray( + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, zero, zero, zero, zero, + ); + } + + statsArray[0] = 0; // dev + statsArray[1] = 0; // mode (no file type bits) + statsArray[2] = 0; // nlink + statsArray[3] = 0; // uid + statsArray[4] = 0; // gid + statsArray[5] = 0; // rdev + statsArray[6] = 0; // blksize + statsArray[7] = 0; // ino + statsArray[8] = 0; // size + statsArray[9] = 0; // blocks + statsArray[10] = 0; // atime_sec + statsArray[11] = 0; // atime_nsec + statsArray[12] = 0; // mtime_sec + statsArray[13] = 0; // mtime_nsec + statsArray[14] = 0; // ctime_sec + statsArray[15] = 0; // ctime_nsec + statsArray[16] = 0; // birthtime_sec + statsArray[17] = 0; // birthtime_nsec + + return getStatsFromBinding(statsArray); +} + +module.exports = { + createFileStats, + createDirectoryStats, + createSymlinkStats, + createZeroStats, +}; diff --git a/lib/internal/vfs/streams.js b/lib/internal/vfs/streams.js new file mode 100644 index 00000000000000..79890491bb3556 --- /dev/null +++ b/lib/internal/vfs/streams.js @@ -0,0 +1,353 @@ +'use strict'; + +const { + MathMin, +} = primordials; + +const { Buffer } = require('buffer'); +const { Readable, Writable } = require('stream'); +const { createEBADF } = require('internal/vfs/errors'); +const { getVirtualFd } = require('internal/vfs/fd'); +const { kEmptyObject } = require('internal/util'); +const { validateInteger } = require('internal/validators'); +const { + codes: { + ERR_OUT_OF_RANGE, + }, +} = require('internal/errors'); + +/** + * A readable stream for virtual files. + */ +class VirtualReadStream extends Readable { + #vfs; + #path; + #fd = null; + #end; + #pos; + #content = null; + #autoClose; + + /** + * Number of bytes read so far. + * @type {number} + */ + bytesRead = 0; + + /** + * True until the first read completes. + * @type {boolean} + */ + pending = true; + + /** + * @param {VirtualFileSystem} vfs The VFS instance + * @param {string} filePath The path to the file + * @param {object} [options] Stream options + */ + constructor(vfs, filePath, options = kEmptyObject) { + const { + start, + end, + highWaterMark = 64 * 1024, + encoding, + fd, + ...streamOptions + } = options; + + // Validate start/end matching real ReadStream behavior + if (start !== undefined) { + validateInteger(start, 'start', 0); + } + if (end !== undefined && end !== Infinity) { + validateInteger(end, 'end', 0); + } + if (start !== undefined && end !== undefined && end !== Infinity && + start > end) { + throw new ERR_OUT_OF_RANGE( + 'start', + `<= "end" (here: ${end})`, + start, + ); + } + + super({ ...streamOptions, highWaterMark, encoding }); + + this.#vfs = vfs; + this.#path = filePath; + this.#end = end === undefined ? Infinity : end; + this.#pos = start === undefined ? 0 : start; + this.#autoClose = options.autoClose !== false; + + if (fd !== null && fd !== undefined) { + // Use the already-open file descriptor + this.#fd = fd; + process.nextTick(() => { + this.emit('open', this.#fd); + this.emit('ready'); + }); + } else { + // Open the file on next tick so listeners can be attached. + // Note: #openFile will not throw - if it fails, the stream is destroyed. + process.nextTick(() => this.#openFile()); + } + } + + /** + * Gets the file path. + * @returns {string} + */ + get path() { + return this.#path; + } + + /** + * Opens the virtual file. + * Events are emitted synchronously within this method, which runs + * asynchronously via process.nextTick - matching real fs behavior. + */ + #openFile() { + try { + this.#fd = this.#vfs.openSync(this.#path); + this.emit('open', this.#fd); + this.emit('ready'); + } catch (err) { + this.destroy(err); + } + } + + /** + * Implements the readable _read method. + * @param {number} size Number of bytes to read + */ + _read(size) { + if (this.destroyed || this.#fd === null) { + this.destroy(createEBADF('read')); + return; + } + + // Load content on first read (lazy loading) + if (this.#content === null) { + try { + const vfd = getVirtualFd(this.#fd); + if (!vfd) { + this.destroy(createEBADF('read')); + return; + } + // Use the file handle's readFileSync to get content + this.#content = vfd.entry.readFileSync(); + this.pending = false; + } catch (err) { + this.destroy(err); + return; + } + } + + // Calculate how much to read + // Note: end is inclusive, so we use end + 1 for the upper bound + const endPos = this.#end === Infinity ? this.#content.length : this.#end + 1; + const remaining = MathMin(endPos, this.#content.length) - this.#pos; + if (remaining <= 0) { + this.push(null); + return; + } + + const bytesToRead = MathMin(size, remaining); + const chunk = this.#content.subarray(this.#pos, this.#pos + bytesToRead); + this.#pos += bytesToRead; + this.bytesRead += bytesToRead; + + this.push(chunk); + + // Check if we've reached the end + if (this.#pos >= endPos || this.#pos >= this.#content.length) { + this.push(null); + } + } + + /** + * Closes the file descriptor. + * Note: Does not emit 'close' - the base Readable class handles that. + */ + #close() { + if (this.#fd !== null) { + try { + this.#vfs.closeSync(this.#fd); + } catch { + // Ignore close errors + } + this.#fd = null; + } + } + + /** + * Implements the readable _destroy method. + * @param {Error|null} err The error + * @param {Function} callback Callback + */ + _destroy(err, callback) { + if (this.#autoClose) { + this.#close(); + } + callback(err); + } +} + +/** + * A writable stream for virtual files. + */ +class VirtualWriteStream extends Writable { + #vfs; + #path; + #fd = null; + #autoClose; + #start; + + /** + * Number of bytes written so far. + * @type {number} + */ + bytesWritten = 0; + + /** + * True until the first write completes. + * @type {boolean} + */ + pending = true; + + /** + * @param {VirtualFileSystem} vfs The VFS instance + * @param {string} filePath The path to the file + * @param {object} [options] Stream options + */ + constructor(vfs, filePath, options = kEmptyObject) { + const { + highWaterMark = 64 * 1024, + ...streamOptions + } = options; + + // Validate start matching real WriteStream behavior + if (options.start !== undefined) { + validateInteger(options.start, 'start', 0); + } + + super({ ...streamOptions, highWaterMark }); + + this.#vfs = vfs; + this.#path = filePath; + this.#autoClose = options.autoClose !== false; + this.#start = options.start; + + const fd = options.fd; + if (fd !== null && fd !== undefined) { + // Use the already-open file descriptor + this.#fd = fd; + if (this.#start !== undefined) { + this.#setPosition(this.#start); + } + process.nextTick(() => { + this.emit('open', this.#fd); + this.emit('ready'); + }); + } else { + // Open file synchronously (VFS is in-memory) so writes can proceed + // immediately. Emit events on next tick for listener attachment. + const flags = options.flags || 'w'; + try { + this.#fd = this.#vfs.openSync(this.#path, flags); + if (this.#start !== undefined) { + this.#setPosition(this.#start); + } + } catch (err) { + process.nextTick(() => this.destroy(err)); + return; + } + process.nextTick(() => { + this.emit('open', this.#fd); + this.emit('ready'); + }); + } + } + + /** + * Sets the file handle position for the given fd. + * @param {number} pos The position to set + */ + #setPosition(pos) { + const vfd = getVirtualFd(this.#fd); + if (vfd) { + vfd.entry.position = pos; + } + } + + /** + * Gets the file path. + * @returns {string} + */ + get path() { + return this.#path; + } + + /** + * Implements the writable _write method. + * @param {Buffer|string} chunk Data to write + * @param {string} encoding Encoding + * @param {Function} callback Callback + */ + _write(chunk, encoding, callback) { + if (this.destroyed || this.#fd === null) { + callback(createEBADF('write')); + return; + } + + try { + const buffer = typeof chunk === 'string' ? + Buffer.from(chunk, encoding) : chunk; + this.#vfs.writeSync(this.#fd, buffer, 0, buffer.length, null); + this.bytesWritten += buffer.length; + this.pending = false; + callback(); + } catch (err) { + callback(err); + } + } + + /** + * Implements the writable _final method (flush before close). + * @param {Function} callback Callback + */ + _final(callback) { + callback(); + } + + /** + * Closes the file descriptor. + */ + #close() { + if (this.#fd !== null) { + try { + this.#vfs.closeSync(this.#fd); + } catch { + // Ignore close errors + } + this.#fd = null; + } + } + + /** + * Implements the writable _destroy method. + * @param {Error|null} err The error + * @param {Function} callback Callback + */ + _destroy(err, callback) { + if (this.#autoClose) { + this.#close(); + } + callback(err); + } +} + +module.exports = { + VirtualReadStream, + VirtualWriteStream, +}; diff --git a/lib/internal/vfs/watcher.js b/lib/internal/vfs/watcher.js new file mode 100644 index 00000000000000..f64ba91ba3c9a6 --- /dev/null +++ b/lib/internal/vfs/watcher.js @@ -0,0 +1,688 @@ +'use strict'; + +const { + ArrayPrototypePush, + ObjectAssign, + Promise, + PromiseResolve, + SafeMap, + SafeSet, + SymbolAsyncIterator, +} = primordials; + +const { AbortError } = require('internal/errors'); +const { Buffer } = require('buffer'); +const { EventEmitter } = require('events'); +const { basename, join } = require('path'); +const { + setInterval, + clearInterval, +} = require('timers'); + +/** + * VFSWatcher - Polling-based file/directory watcher for VFS. + * Emits 'change' events when the file content or stats change. + * Compatible with fs.watch() return value interface. + */ +class VFSWatcher extends EventEmitter { + #vfs; + #path; + #interval; + #timer = null; + #lastStats; + #closed = false; + #persistent; + #recursive; + #encoding; + #trackedFiles; + #signal; + #abortHandler = null; + + /** + * @param {VirtualProvider} provider The VFS provider + * @param {string} path The path to watch (provider-relative) + * @param {object} [options] Options + * @param {number} [options.interval] Polling interval in ms (default: 100) + * @param {boolean} [options.persistent] Keep process alive (default: true) + * @param {boolean} [options.recursive] Watch subdirectories (default: false) + * @param {AbortSignal} [options.signal] AbortSignal for cancellation + */ + constructor(provider, path, options = {}) { + super(); + + this.#vfs = provider; + this.#path = path; + this.#interval = options.interval ?? 100; + this.#persistent = options.persistent !== false; + this.#recursive = options.recursive === true; + this.#encoding = options.encoding; + this.#trackedFiles = new SafeMap(); // path -> { stats, relativePath } + this.#signal = options.signal; + + // Handle AbortSignal + if (this.#signal) { + if (this.#signal.aborted) { + this.close(); + return; + } + this.#abortHandler = () => this.close(); + this.#signal.addEventListener('abort', this.#abortHandler, { once: true }); + } + + // Get initial stats + this.#lastStats = this.#getStats(); + + // If watching a directory, build file list + if (this.#lastStats?.isDirectory()) { + if (this.#recursive) { + this.#buildFileList(this.#path, ''); + } else { + this.#buildChildList(this.#path); + } + } + + // Start polling + this.#startPolling(); + } + + /** + * Encodes a filename according to the watcher's encoding option. + * @param {string} filename The filename to encode + * @returns {string|Buffer} The encoded filename + */ + #encodeFilename(filename) { + if (this.#encoding === 'buffer') { + return Buffer.from(filename); + } + return filename; + } + + /** + * Gets stats for the watched path. + * @returns {Stats|null} The stats or null if file doesn't exist + */ + #getStats() { + try { + return this.#vfs.statSync(this.#path); + } catch { + return null; + } + } + + /** + * Starts the polling timer. + */ + #startPolling() { + if (this.#closed) return; + + this.#timer = setInterval(() => this.#poll(), this.#interval); + + // If not persistent, unref the timer to allow process to exit + if (!this.#persistent && this.#timer.unref) { + this.#timer.unref(); + } + } + + /** + * Polls for changes. + */ + #poll() { + if (this.#closed) return; + + // For directory watching, poll tracked children + if (this.#lastStats?.isDirectory()) { + this.#pollDirectory(); + return; + } + + // For single file watching + const newStats = this.#getStats(); + + if (this.#statsChanged(this.#lastStats, newStats)) { + const eventType = this.#determineEventType(this.#lastStats, newStats); + const filename = this.#encodeFilename(basename(this.#path)); + this.emit('change', eventType, filename); + } + + this.#lastStats = newStats; + } + + /** + * Polls directory children for changes, detecting new and deleted files. + */ + #pollDirectory() { + // Rescan for new files + if (this.#recursive) { + this.#rescanRecursive(this.#path, ''); + } else { + this.#rescanChildren(this.#path); + } + + // Check tracked files for changes/deletions + for (const { 0: filePath, 1: info } of this.#trackedFiles) { + const newStats = this.#getStatsFor(filePath); + if (newStats === null && info.stats !== null) { + // File was deleted + this.emit('change', 'rename', this.#encodeFilename(info.relativePath)); + this.#trackedFiles.delete(filePath); + } else if (this.#statsChanged(info.stats, newStats)) { + const eventType = this.#determineEventType(info.stats, newStats); + this.emit('change', eventType, this.#encodeFilename(info.relativePath)); + info.stats = newStats; + } + } + } + + /** + * Rescans direct children for new entries. + * @param {string} dirPath The directory path + */ + #rescanChildren(dirPath) { + try { + const entries = this.#vfs.readdirSync(dirPath); + for (const name of entries) { + const fullPath = join(dirPath, name); + if (!this.#trackedFiles.has(fullPath)) { + const stats = this.#getStatsFor(fullPath); + this.#trackedFiles.set(fullPath, { + stats, + relativePath: name, + }); + this.emit('change', 'rename', this.#encodeFilename(name)); + } + } + } catch { + // Directory might not exist or be readable + } + } + + /** + * Recursively rescans for new entries. + * @param {string} dirPath The directory path + * @param {string} relativePath The relative path from watched root + */ + #rescanRecursive(dirPath, relativePath) { + try { + const entries = this.#vfs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dirPath, entry.name); + const relPath = relativePath ? + join(relativePath, entry.name) : entry.name; + + if (entry.isDirectory()) { + this.#rescanRecursive(fullPath, relPath); + } else if (!this.#trackedFiles.has(fullPath)) { + const stats = this.#getStatsFor(fullPath); + this.#trackedFiles.set(fullPath, { + stats, + relativePath: relPath, + }); + this.emit('change', 'rename', this.#encodeFilename(relPath)); + } + } + } catch { + // Directory might not exist or be readable + } + } + + /** + * Gets stats for a specific path. + * @param {string} filePath The file path + * @returns {Stats|null} + */ + #getStatsFor(filePath) { + try { + return this.#vfs.statSync(filePath); + } catch { + return null; + } + } + + /** + * Builds the list of files to track for recursive watching. + * @param {string} dirPath The directory path + * @param {string} relativePath The relative path from the watched root + */ + #buildFileList(dirPath, relativePath) { + try { + const entries = this.#vfs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dirPath, entry.name); + const relPath = relativePath ? join(relativePath, entry.name) : entry.name; + + if (entry.isDirectory()) { + // Recurse into subdirectory + this.#buildFileList(fullPath, relPath); + } else { + // Track the file + const stats = this.#getStatsFor(fullPath); + this.#trackedFiles.set(fullPath, { + stats, + relativePath: relPath, + }); + } + } + } catch { + // Directory might not exist or be readable + } + } + + /** + * Builds a list of direct children to track for non-recursive watching. + * @param {string} dirPath The directory path + */ + #buildChildList(dirPath) { + try { + const entries = this.#vfs.readdirSync(dirPath); + for (const name of entries) { + const fullPath = join(dirPath, name); + const stats = this.#getStatsFor(fullPath); + this.#trackedFiles.set(fullPath, { + stats, + relativePath: name, + }); + } + } catch { + // Directory might not exist or be readable + } + } + + /** + * Checks if stats have changed. + * @param {Stats|null} oldStats Previous stats + * @param {Stats|null} newStats Current stats + * @returns {boolean} True if stats changed + */ + #statsChanged(oldStats, newStats) { + // File created or deleted + if ((oldStats === null) !== (newStats === null)) { + return true; + } + + // Both null - no change + if (oldStats === null && newStats === null) { + return false; + } + + // Compare mtime and size + if (oldStats.mtimeMs !== newStats.mtimeMs) { + return true; + } + if (oldStats.size !== newStats.size) { + return true; + } + + return false; + } + + /** + * Determines the event type based on stats change. + * @param {Stats|null} oldStats Previous stats + * @param {Stats|null} newStats Current stats + * @returns {string} 'rename' or 'change' + */ + #determineEventType(oldStats, newStats) { + // File was created or deleted + if ((oldStats === null) !== (newStats === null)) { + return 'rename'; + } + // Content changed + return 'change'; + } + + /** + * Closes the watcher and stops polling. + */ + close() { + if (this.#closed) return; + this.#closed = true; + + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = null; + } + + // Clear tracked files + this.#trackedFiles.clear(); + + // Remove abort handler + if (this.#signal && this.#abortHandler) { + this.#signal.removeEventListener('abort', this.#abortHandler); + } + + this.emit('close'); + } + + /** + * Alias for close() - compatibility with FSWatcher. + * @returns {this} + */ + unref() { + this.#timer?.unref?.(); + return this; + } + + /** + * Makes the timer keep the process alive - compatibility with FSWatcher. + * @returns {this} + */ + ref() { + this.#timer?.ref?.(); + return this; + } +} + +/** + * VFSStatWatcher - Polling-based stat watcher for VFS. + * Emits 'change' events with current and previous stats. + * Compatible with fs.watchFile() return value interface. + */ +class VFSStatWatcher extends EventEmitter { + #vfs; + #path; + #interval; + #persistent; + #bigint; + #closed = false; + #timer = null; + #lastStats; + #listeners; + + /** + * @param {VirtualProvider} provider The VFS provider + * @param {string} path The path to watch (provider-relative) + * @param {object} [options] Options + * @param {number} [options.interval] Polling interval in ms (default: 5007) + * @param {boolean} [options.persistent] Keep process alive (default: true) + */ + constructor(provider, path, options = {}) { + super(); + + this.#vfs = provider; + this.#path = path; + this.#interval = options.interval ?? 5007; + this.#persistent = options.persistent !== false; + this.#bigint = options.bigint === true; + this.#listeners = new SafeSet(); + + // Get initial stats + this.#lastStats = this.#getStats(); + + // Start polling + this.#startPolling(); + } + + /** + * Gets stats for the watched path. + * @returns {Stats} The stats (with zeroed values if file doesn't exist) + */ + #getStats() { + try { + return this.#vfs.statSync(this.#path, { bigint: this.#bigint }); + } catch { + // Return a zeroed stats object for non-existent files + // This matches Node.js behavior + return this.#createZeroStats(); + } + } + + /** + * Creates a zeroed stats object for non-existent files. + * @returns {object} Zeroed stats + */ + #createZeroStats() { + const { createZeroStats } = require('internal/vfs/stats'); + return createZeroStats({ bigint: this.#bigint }); + } + + /** + * Starts the polling timer. + */ + #startPolling() { + if (this.#closed) return; + + this.#timer = setInterval(() => this.#poll(), this.#interval); + + // If not persistent, unref the timer to allow process to exit + if (!this.#persistent && this.#timer.unref) { + this.#timer.unref(); + } + } + + /** + * Polls for changes. + */ + #poll() { + if (this.#closed) return; + + const newStats = this.#getStats(); + + if (this.#statsChanged(this.#lastStats, newStats)) { + const prevStats = this.#lastStats; + this.#lastStats = newStats; + this.emit('change', newStats, prevStats); + } + } + + /** + * Checks if stats have changed. + * @param {Stats} oldStats Previous stats + * @param {Stats} newStats Current stats + * @returns {boolean} True if stats changed + */ + #statsChanged(oldStats, newStats) { + // Compare mtime and ctime + if (oldStats.mtimeMs !== newStats.mtimeMs) { + return true; + } + if (oldStats.ctimeMs !== newStats.ctimeMs) { + return true; + } + if (oldStats.size !== newStats.size) { + return true; + } + return false; + } + + /** + * Adds a listener for the given event. + * Tracks 'change' listeners for internal bookkeeping. + * @param {string} event The event name + * @param {Function} listener The listener function + * @returns {this} + */ + addListener(event, listener) { + if (event === 'change') { + this.#listeners.add(listener); + } + super.addListener(event, listener); + return this; + } + + /** + * Removes a listener for the given event. + * @param {string} event The event name + * @param {Function} listener The listener function + * @returns {this} + */ + removeListener(event, listener) { + if (event === 'change') { + this.#listeners.delete(listener); + } + super.removeListener(event, listener); + return this; + } + + /** + * Removes all listeners for an event. + * Overrides EventEmitter to also clear internal #listeners tracking. + * @param {string} eventName The event name + * @returns {this} + */ + removeAllListeners(eventName) { + if (eventName === 'change') { + this.#listeners.clear(); + } + super.removeAllListeners(eventName); + return this; + } + + /** + * Returns true if there are no listeners. + * @returns {boolean} + */ + hasNoListeners() { + return this.#listeners.size === 0; + } + + /** + * Stops the watcher. + */ + stop() { + if (this.#closed) return; + this.#closed = true; + + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = null; + } + + this.emit('stop'); + } + + /** + * Makes the timer not keep the process alive. + * @returns {this} + */ + unref() { + this.#timer?.unref?.(); + return this; + } + + /** + * Makes the timer keep the process alive. + * @returns {this} + */ + ref() { + this.#timer?.ref?.(); + return this; + } +} + +/** + * VFSWatchAsyncIterable - Async iterable wrapper for VFSWatcher. + * Compatible with fs.promises.watch() return value interface. + */ +const kMaxPendingEvents = 1024; + +class VFSWatchAsyncIterable { + #watcher; + #closed = false; + #pendingEvents = []; + #pendingResolvers = []; + + /** + * @param {VirtualProvider} provider The VFS provider + * @param {string} path The path to watch (provider-relative) + * @param {object} [options] Options + */ + constructor(provider, path, options = {}) { + // Strip signal from options passed to VFSWatcher - we handle abort + // at the iterable level to reject pending next() with AbortError + // instead of resolving with done:true via the 'close' event. + const signal = options.signal; + const watcherOptions = ObjectAssign({ __proto__: null }, options); + delete watcherOptions.signal; + this.#watcher = new VFSWatcher(provider, path, watcherOptions); + + this.#watcher.on('change', (eventType, filename) => { + const event = { eventType, filename }; + if (this.#pendingResolvers.length > 0) { + const { resolve } = this.#pendingResolvers.shift(); + resolve({ value: event, done: false }); + } else if (this.#pendingEvents.length < kMaxPendingEvents) { + ArrayPrototypePush(this.#pendingEvents, event); + } + // Drop events when queue is full to prevent unbounded memory growth + }); + + this.#watcher.on('close', () => { + this.#closed = true; + // Resolve any pending iterators + while (this.#pendingResolvers.length > 0) { + const { resolve } = this.#pendingResolvers.shift(); + resolve({ value: undefined, done: true }); + } + }); + + // Handle abort signal - reject pending next() with AbortError + if (signal) { + const onAbort = () => { + this.#closed = true; + const err = new AbortError(undefined, { cause: signal.reason }); + while (this.#pendingResolvers.length > 0) { + const { reject } = this.#pendingResolvers.shift(); + reject(err); + } + this.#watcher.close(); + }; + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener('abort', onAbort, { once: true }); + } + } + } + + /** + * Returns the async iterator. + * @returns {AsyncIterator} + */ + [SymbolAsyncIterator]() { + return this; + } + + /** + * Gets the next event. + * @returns {Promise} + */ + next() { + if (this.#closed) { + return PromiseResolve({ value: undefined, done: true }); + } + + if (this.#pendingEvents.length > 0) { + const event = this.#pendingEvents.shift(); + return PromiseResolve({ value: event, done: false }); + } + + return new Promise((resolve, reject) => { + ArrayPrototypePush(this.#pendingResolvers, { resolve, reject }); + }); + } + + /** + * Closes the iterator and underlying watcher. + * @returns {Promise} + */ + return() { + this.#watcher.close(); + return PromiseResolve({ value: undefined, done: true }); + } + + /** + * Handles iterator throw. + * @param {Error} error The error to throw + * @returns {Promise} + */ + throw(error) { + this.#watcher.close(); + return PromiseResolve({ value: undefined, done: true }); + } +} + +module.exports = { + VFSWatcher, + VFSStatWatcher, + VFSWatchAsyncIterable, +}; diff --git a/lib/vfs.js b/lib/vfs.js new file mode 100644 index 00000000000000..0d12229aca72cd --- /dev/null +++ b/lib/vfs.js @@ -0,0 +1,37 @@ +'use strict'; + +const { + FunctionPrototypeSymbolHasInstance, +} = primordials; + +const { VirtualFileSystem } = require('internal/vfs/file_system'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { MemoryProvider } = require('internal/vfs/providers/memory'); +const { RealFSProvider } = require('internal/vfs/providers/real'); + +/** + * Creates a new VirtualFileSystem instance. + * @param {VirtualProvider} [provider] The provider to use (defaults to MemoryProvider) + * @param {object} [options] Configuration options + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks (default: true) + * @param {boolean} [options.virtualCwd] Whether to enable virtual working directory + * @returns {VirtualFileSystem} + */ +function create(provider, options) { + // Handle case where first arg is options (no provider) + if (provider != null && + !FunctionPrototypeSymbolHasInstance(VirtualProvider, provider) && + typeof provider === 'object') { + options = provider; + provider = undefined; + } + return new VirtualFileSystem(provider, options); +} + +module.exports = { + create, + VirtualFileSystem, + VirtualProvider, + MemoryProvider, + RealFSProvider, +}; diff --git a/src/node_builtins.cc b/src/node_builtins.cc index b098a41cca9ea4..20874fcbd5fac2 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -148,6 +148,7 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { "sqlite", // Experimental. "stream/iter", // Experimental. "sys", // Deprecated. + "vfs", // Experimental. "wasi", // Experimental. "zlib/iter", // Experimental. #if !HAVE_SQLITE diff --git a/src/node_options.cc b/src/node_options.cc index bbb72d2ba1bcf4..f3612022d490c6 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -610,6 +610,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "experimental iterable streams API (node:stream/iter)", &EnvironmentOptions::experimental_stream_iter, kAllowedInEnvvar); + AddOption("--experimental-vfs", + "experimental node:vfs module", + &EnvironmentOptions::experimental_vfs, + kAllowedInEnvvar); AddOption("--experimental-quic", #ifndef OPENSSL_NO_QUIC "experimental QUIC support", diff --git a/src/node_options.h b/src/node_options.h index e910cb011431ab..635a6f3bc4ca31 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -132,6 +132,7 @@ class EnvironmentOptions : public Options { bool experimental_websocket = true; bool experimental_sqlite = HAVE_SQLITE; bool experimental_stream_iter = EXPERIMENTALS_DEFAULT_VALUE; + bool experimental_vfs = EXPERIMENTALS_DEFAULT_VALUE; bool webstorage = HAVE_SQLITE; bool experimental_quic = EXPERIMENTALS_DEFAULT_VALUE; std::string localstorage_file; diff --git a/test/parallel/test-process-get-builtin.mjs b/test/parallel/test-process-get-builtin.mjs index 3582dcebca9eba..5cbf28430109ed 100644 --- a/test/parallel/test-process-get-builtin.mjs +++ b/test/parallel/test-process-get-builtin.mjs @@ -38,6 +38,8 @@ if (!hasIntl) { } // TODO(@jasnell): Remove this once node:quic graduates from unflagged. publicBuiltins.delete('node:quic'); +// Remove this once node:vfs graduates from unflagged. +publicBuiltins.delete('node:vfs'); if (!hasInspector) { publicBuiltins.delete('inspector'); diff --git a/test/parallel/test-require-resolve.js b/test/parallel/test-require-resolve.js index 281b0cc6087ab9..44422706e186cc 100644 --- a/test/parallel/test-require-resolve.js +++ b/test/parallel/test-require-resolve.js @@ -64,6 +64,8 @@ require(fixtures.path('resolve-paths', 'default', 'verify-paths.js')); if (mod === 'node:quic') return; // TODO: Remove once node:ffi is no longer flagged if (mod === 'node:ffi') return; + // Remove once node:vfs is no longer flagged + if (mod === 'node:vfs') return; if (mod === 'node:sqlite' && !common.hasSQLite) return; assert.strictEqual(require.resolve.paths(mod), null); if (!mod.startsWith('node:')) { diff --git a/test/parallel/test-vfs-access-modes.js b/test/parallel/test-vfs-access-modes.js new file mode 100644 index 00000000000000..6b4854b204aebc --- /dev/null +++ b/test/parallel/test-vfs-access-modes.js @@ -0,0 +1,41 @@ +// Flags: --experimental-vfs +'use strict'; + +// access / accessSync honour the R_OK / W_OK / X_OK / F_OK mode bits and +// throw EACCES when the file's permission bits don't allow the request. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const vfs = require('node:vfs'); + +const { R_OK, W_OK, X_OK } = fs.constants; + +const myVfs = vfs.create(); + +// No read permission (mode 0o222 → owner has W only) +myVfs.writeFileSync('/no-r.txt', 'x'); +myVfs.chmodSync('/no-r.txt', 0o222); +assert.throws(() => myVfs.accessSync('/no-r.txt', R_OK), { code: 'EACCES' }); +assert.rejects(myVfs.promises.access('/no-r.txt', R_OK), + { code: 'EACCES' }).then(common.mustCall()); + +// No write permission (mode 0o444 → owner has R only) +myVfs.writeFileSync('/no-w.txt', 'x'); +myVfs.chmodSync('/no-w.txt', 0o444); +assert.throws(() => myVfs.accessSync('/no-w.txt', W_OK), { code: 'EACCES' }); +assert.rejects(myVfs.promises.access('/no-w.txt', W_OK), + { code: 'EACCES' }).then(common.mustCall()); + +// No execute permission (mode 0o644) +myVfs.writeFileSync('/no-x.txt', 'x'); +myVfs.chmodSync('/no-x.txt', 0o644); +assert.throws(() => myVfs.accessSync('/no-x.txt', X_OK), { code: 'EACCES' }); +assert.rejects(myVfs.promises.access('/no-x.txt', X_OK), + { code: 'EACCES' }).then(common.mustCall()); + +// F_OK (mode 0) is an existence-only check and does not require permission +myVfs.accessSync('/no-r.txt', 0); + +// Mode passed as null also exits early (existence-only) +myVfs.accessSync('/no-r.txt', null); diff --git a/test/parallel/test-vfs-append-write.js b/test/parallel/test-vfs-append-write.js new file mode 100644 index 00000000000000..fee8a137adee35 --- /dev/null +++ b/test/parallel/test-vfs-append-write.js @@ -0,0 +1,19 @@ +// Flags: --experimental-vfs +'use strict'; + +// writeSync in append mode must append, not overwrite. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/append.txt', 'init'); + +const fd = myVfs.openSync('/append.txt', 'a'); +const buf = Buffer.from(' more'); +myVfs.writeSync(fd, buf, 0, buf.length); +myVfs.closeSync(fd); + +const content = myVfs.readFileSync('/append.txt', 'utf8'); +assert.strictEqual(content, 'init more'); diff --git a/test/parallel/test-vfs-bigint-position.js b/test/parallel/test-vfs-bigint-position.js new file mode 100644 index 00000000000000..d869f93947f852 --- /dev/null +++ b/test/parallel/test-vfs-bigint-position.js @@ -0,0 +1,18 @@ +// Flags: --experimental-vfs +'use strict'; + +// VFS readSync should accept a BigInt position parameter. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'abcde'); + +const fd = myVfs.openSync('/file.txt', 'r'); +const buf = Buffer.alloc(1); +const bytesRead = myVfs.readSync(fd, buf, 0, 1, 1n); +assert.strictEqual(bytesRead, 1); +assert.strictEqual(buf.toString(), 'b'); +myVfs.closeSync(fd); diff --git a/test/parallel/test-vfs-callback-api.js b/test/parallel/test-vfs-callback-api.js new file mode 100644 index 00000000000000..dd95a5d75877e5 --- /dev/null +++ b/test/parallel/test-vfs-callback-api.js @@ -0,0 +1,154 @@ +// Flags: --experimental-vfs +'use strict'; + +// Exercise the VFS callback-style async API on every method. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.mkdirSync('/dir/sub', { recursive: true }); +myVfs.writeFileSync('/dir/file.txt', 'hello'); +myVfs.writeFileSync('/dir/other.txt', 'other'); + +// readFile (no options) +myVfs.readFile('/dir/file.txt', common.mustSucceed((data) => { + assert.ok(Buffer.isBuffer(data)); +})); + +// writeFile + appendFile (no options) -> readFile +myVfs.writeFile('/cb-write.txt', 'a', common.mustSucceed(() => { + myVfs.readFile('/cb-write.txt', 'utf8', common.mustSucceed((data) => { + assert.strictEqual(data, 'a'); + })); +})); + +// stat / lstat (with and without options) +myVfs.stat('/dir/file.txt', common.mustSucceed((st) => { + assert.strictEqual(st.size, 5); +})); +myVfs.stat('/dir/file.txt', { bigint: true }, common.mustSucceed((st) => { + assert.strictEqual(typeof st.size, 'bigint'); +})); +myVfs.lstat('/dir/file.txt', common.mustSucceed((st) => { + assert.ok(st.isFile()); +})); + +// readdir +myVfs.readdir('/dir', common.mustSucceed((names) => { + assert.ok(names.includes('file.txt')); +})); + +// realpath +myVfs.realpath('/dir/file.txt', common.mustSucceed((p) => { + assert.strictEqual(p, '/dir/file.txt'); +})); + +// access (with and without mode) +myVfs.access('/dir/file.txt', common.mustSucceed()); +myVfs.access('/dir/file.txt', 0, common.mustSucceed()); +myVfs.access('/missing.txt', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); +})); + +// open / read / write / close cb chain +myVfs.open('/dir/file.txt', common.mustSucceed((fd) => { + const buf = Buffer.alloc(5); + myVfs.read(fd, buf, 0, 5, 0, common.mustSucceed((bytesRead) => { + assert.strictEqual(bytesRead, 5); + assert.strictEqual(buf.toString(), 'hello'); + myVfs.close(fd, common.mustSucceed()); + })); +})); + +// Open with explicit flags / mode +myVfs.open('/dir/new1.txt', 'w', common.mustSucceed((fd) => { + const buf = Buffer.from('xyz'); + myVfs.write(fd, buf, 0, 3, 0, common.mustSucceed((bytesWritten) => { + assert.strictEqual(bytesWritten, 3); + myVfs.fstat(fd, common.mustSucceed((st) => { + assert.strictEqual(st.size, 3); + myVfs.ftruncate(fd, 1, common.mustSucceed(() => { + myVfs.close(fd, common.mustCall()); + })); + })); + })); +})); + +// Open with explicit flags, no mode arg form +myVfs.open('/dir/new2.txt', 'w', 0o644, common.mustSucceed((fd) => { + myVfs.close(fd, common.mustCall()); +})); + +// rm callback (file) +myVfs.writeFileSync('/cb-rm.txt', 'x'); +myVfs.rm('/cb-rm.txt', common.mustSucceed(() => { + assert.strictEqual(myVfs.existsSync('/cb-rm.txt'), false); +})); + +// Rm callback with options (recursive) +myVfs.mkdirSync('/cb-rm-dir/sub', { recursive: true }); +myVfs.writeFileSync('/cb-rm-dir/sub/f.txt', 'x'); +myVfs.rm('/cb-rm-dir', { recursive: true }, common.mustSucceed()); + +// Rm callback failure path +myVfs.rm('/missing-rm', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); +})); + +// truncate / ftruncate cb +myVfs.writeFileSync('/cb-tr.txt', 'abcdef'); +myVfs.truncate('/cb-tr.txt', 3, common.mustSucceed(() => { + assert.strictEqual(myVfs.readFileSync('/cb-tr.txt', 'utf8'), 'abc'); +})); +myVfs.truncate('/missing-tr.txt', common.mustCall((err) => { + assert.ok(err); +})); +myVfs.ftruncate(0xFFFFFFF /* invalid fd */, common.mustCall((err) => { + assert.strictEqual(err.code, 'EBADF'); +})); + +// link cb +myVfs.writeFileSync('/cb-link-src.txt', 'x'); +myVfs.link('/cb-link-src.txt', '/cb-link-dst.txt', common.mustSucceed()); +myVfs.link('/missing-src.txt', '/cb-bad-link.txt', common.mustCall((err) => { + assert.ok(err); +})); + +// mkdtemp cb +myVfs.mkdtemp('/tmp-', common.mustSucceed((p) => { + assert.ok(p.startsWith('/tmp-')); +})); +myVfs.mkdtemp('/tmp-', {}, common.mustSucceed((p) => { + assert.ok(p.startsWith('/tmp-')); +})); + +// opendir cb +myVfs.opendir('/dir', common.mustSucceed((dir) => { + assert.strictEqual(dir.path, '/dir'); + dir.closeSync(); +})); +myVfs.opendir('/missing-dir', common.mustCall((err) => { + assert.ok(err); +})); + +// EBADF callback paths +myVfs.read(0xFFFFFFF, Buffer.alloc(1), 0, 1, 0, common.mustCall((err) => { + assert.strictEqual(err.code, 'EBADF'); +})); +myVfs.write(0xFFFFFFF, Buffer.alloc(1), 0, 1, 0, common.mustCall((err) => { + assert.strictEqual(err.code, 'EBADF'); +})); +myVfs.close(0xFFFFFFF, common.mustCall((err) => { + assert.strictEqual(err.code, 'EBADF'); +})); +myVfs.fstat(0xFFFFFFF, common.mustCall((err) => { + assert.strictEqual(err.code, 'EBADF'); +})); + +// readlink cb +myVfs.symlinkSync('/dir/file.txt', '/cb-link'); +myVfs.readlink('/cb-link', common.mustSucceed((target) => { + assert.strictEqual(target, '/dir/file.txt'); +})); diff --git a/test/parallel/test-vfs-copyfile-mode.js b/test/parallel/test-vfs-copyfile-mode.js new file mode 100644 index 00000000000000..2d701f4f105d27 --- /dev/null +++ b/test/parallel/test-vfs-copyfile-mode.js @@ -0,0 +1,52 @@ +// Flags: --experimental-vfs +'use strict'; + +// Tests for VFS copyFile mode support: +// - COPYFILE_EXCL throws when destination exists +// - Without COPYFILE_EXCL, copy overwrites destination + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const vfs = require('node:vfs'); + +const { COPYFILE_EXCL } = fs.constants; + +// copyFileSync with COPYFILE_EXCL throws when destination exists. +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/src.txt', 'src'); + myVfs.writeFileSync('/dst.txt', 'dst'); + + assert.throws( + () => myVfs.copyFileSync('/src.txt', '/dst.txt', COPYFILE_EXCL), + { code: 'EEXIST' }, + ); + + assert.strictEqual(myVfs.readFileSync('/dst.txt', 'utf8'), 'dst'); +} + +// copyFileSync without COPYFILE_EXCL succeeds and overwrites. +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/src.txt', 'new-content'); + myVfs.writeFileSync('/dst.txt', 'old'); + + myVfs.copyFileSync('/src.txt', '/dst.txt'); + assert.strictEqual(myVfs.readFileSync('/dst.txt', 'utf8'), 'new-content'); +} + +// promises.copyFile with COPYFILE_EXCL +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/src.txt', 's'); + myVfs.writeFileSync('/dst.txt', 'd'); + + await assert.rejects( + myVfs.promises.copyFile('/src.txt', '/dst.txt', COPYFILE_EXCL), + { code: 'EEXIST' }, + ); + + await myVfs.promises.copyFile('/src.txt', '/dst.txt'); + assert.strictEqual(myVfs.readFileSync('/dst.txt', 'utf8'), 's'); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-create.js b/test/parallel/test-vfs-create.js new file mode 100644 index 00000000000000..764e276f0e9148 --- /dev/null +++ b/test/parallel/test-vfs-create.js @@ -0,0 +1,65 @@ +// Flags: --experimental-vfs +'use strict'; + +// Constructor variants and option validation for vfs.create() and +// `new VirtualFileSystem(...)`. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// vfs.create() with no arguments uses the default MemoryProvider +{ + const myVfs = vfs.create(); + assert.ok(myVfs.provider instanceof vfs.MemoryProvider); +} + +// vfs.create with first arg as options object (no provider) +{ + const myVfs = vfs.create({ emitExperimentalWarning: false }); + assert.ok(myVfs); + assert.ok(myVfs.provider instanceof vfs.MemoryProvider); +} + +// vfs.create with explicit provider +{ + const provider = new vfs.MemoryProvider(); + const myVfs = vfs.create(provider); + assert.strictEqual(myVfs.provider, provider); +} + +// new VirtualFileSystem(options) directly +{ + const myVfs = new vfs.VirtualFileSystem({ emitExperimentalWarning: false }); + assert.ok(myVfs); +} + +// emitExperimentalWarning option must be a boolean +{ + assert.throws( + () => new vfs.VirtualFileSystem({ emitExperimentalWarning: 'not-bool' }), + { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// existsSync swallows ALL errors from the provider, not just ENOENT +{ + class ThrowingProvider extends vfs.VirtualProvider { + existsSync() { throw new Error('boom'); } + } + const myVfs = vfs.create(new ThrowingProvider()); + assert.strictEqual(myVfs.existsSync('/anything'), false); +} + +// Walking a path through a regular-file parent throws ENOTDIR +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'x'); + assert.throws(() => myVfs.writeFileSync('/file.txt/oops', 'y'), + { code: 'ENOTDIR' }); +} + +// statSync('/') returns the root directory +{ + const myVfs = vfs.create(); + assert.ok(myVfs.statSync('/').isDirectory()); +} diff --git a/test/parallel/test-vfs-ctime-update.js b/test/parallel/test-vfs-ctime-update.js new file mode 100644 index 00000000000000..227732ce74cde0 --- /dev/null +++ b/test/parallel/test-vfs-ctime-update.js @@ -0,0 +1,49 @@ +// Flags: --experimental-vfs +'use strict'; + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Test that writeFileSync updates both mtime and ctime +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'initial'); + + const stat1 = myVfs.statSync('/file.txt'); + const oldCtime = stat1.ctimeMs; + + myVfs.writeFileSync('/file.txt', 'updated'); + const stat2 = myVfs.statSync('/file.txt'); + assert.ok(stat2.mtimeMs >= oldCtime); + assert.ok(stat2.ctimeMs >= oldCtime); + // Ctime and mtime should be the same value (both set from same write) + assert.strictEqual(stat2.ctimeMs, stat2.mtimeMs); +} + +// Test that writeSync via file handle updates ctime +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'initial'); + + const fd = myVfs.openSync('/file.txt', 'r+'); + const buf = Buffer.from('X'); + myVfs.writeSync(fd, buf, 0, 1, 0); + myVfs.closeSync(fd); + + const stat = myVfs.statSync('/file.txt'); + assert.strictEqual(stat.ctimeMs, stat.mtimeMs); +} + +// Test that truncateSync updates ctime +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'some content'); + + const fd = myVfs.openSync('/file.txt', 'r+'); + myVfs.ftruncateSync(fd, 0); + myVfs.closeSync(fd); + + const stat = myVfs.statSync('/file.txt'); + assert.strictEqual(stat.ctimeMs, stat.mtimeMs); +} diff --git a/test/parallel/test-vfs-dir-handle.js b/test/parallel/test-vfs-dir-handle.js new file mode 100644 index 00000000000000..6a26a69ce2d485 --- /dev/null +++ b/test/parallel/test-vfs-dir-handle.js @@ -0,0 +1,114 @@ +// Flags: --experimental-vfs +'use strict'; + +// Exercise the VirtualDir handle returned by opendirSync. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.mkdirSync('/d'); +myVfs.writeFileSync('/d/a.txt', 'a'); +myVfs.writeFileSync('/d/b.txt', 'b'); +myVfs.mkdirSync('/d/sub'); + +// readSync iteration +{ + const dir = myVfs.opendirSync('/d'); + assert.strictEqual(dir.path, '/d'); + const names = []; + let entry; + while ((entry = dir.readSync()) !== null) { + names.push(entry.name); + } + assert.deepStrictEqual(names.sort(), ['a.txt', 'b.txt', 'sub']); + dir.closeSync(); + // Closing again must throw + assert.throws(() => dir.closeSync(), { code: 'ERR_DIR_CLOSED' }); + // Reading after close throws + assert.throws(() => dir.readSync(), { code: 'ERR_DIR_CLOSED' }); +} + +// for-await iteration +(async () => { + const dir = myVfs.opendirSync('/d'); + const names = []; + for await (const entry of dir) { + names.push(entry.name); + } + assert.deepStrictEqual(names.sort(), ['a.txt', 'b.txt', 'sub']); +})().then(common.mustCall()); + +// Async read with callback +(async () => { + const dir = myVfs.opendirSync('/d'); + await new Promise((resolve, reject) => { + dir.read((err, entry) => { + if (err) reject(err); + else resolve(entry); + }); + }); + await new Promise((resolve, reject) => { + dir.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +})().then(common.mustCall()); + +// Async read without callback returns a promise +(async () => { + const dir = myVfs.opendirSync('/d'); + const entry = await dir.read(); + assert.ok(entry); + await dir.close(); +})().then(common.mustCall()); + +// using/explicit resource management +{ + const dir = myVfs.opendirSync('/d'); + dir[Symbol.dispose](); + assert.throws(() => dir.readSync(), { code: 'ERR_DIR_CLOSED' }); +} + +// opendir (callback) +myVfs.opendir('/d', common.mustSucceed((dir) => { + assert.strictEqual(dir.path, '/d'); + dir.closeSync(); +})); + +// read() callback on a closed dir delivers ERR_DIR_CLOSED +{ + const dir = myVfs.opendirSync('/d'); + dir.closeSync(); + dir.read(common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_DIR_CLOSED'); + })); + // entries() iteration on a closed dir rejects with ERR_DIR_CLOSED + (async () => { + await assert.rejects( + // eslint-disable-next-line no-unused-vars + (async () => { for await (const _ of dir.entries()); })(), + { code: 'ERR_DIR_CLOSED' }); + })().then(common.mustCall()); + // [Symbol.dispose] is a no-op on an already-closed dir (must not throw) + dir[Symbol.dispose](); +} + +// Async dir.close() returns a promise when invoked without a callback +(async () => { + const dir = myVfs.opendirSync('/d'); + await dir.close(); +})().then(common.mustCall()); + +// opendirSync without options object +{ + const dir = myVfs.opendirSync('/d'); + dir.closeSync(); +} + +// Opendir error path (missing directory) +myVfs.opendir('/missing-dir', common.mustCall((err) => { + assert.ok(err); +})); diff --git a/test/parallel/test-vfs-fd.js b/test/parallel/test-vfs-fd.js new file mode 100644 index 00000000000000..ec9145189da299 --- /dev/null +++ b/test/parallel/test-vfs-fd.js @@ -0,0 +1,319 @@ +// Flags: --experimental-vfs +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Test openSync and closeSync +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'hello world'); + + const fd = myVfs.openSync('/file.txt'); + assert.ok((fd & 0x40000000) !== 0, 'VFS fd should have bit 30 set'); + myVfs.closeSync(fd); +} + +// Test openSync with non-existent file +{ + const myVfs = vfs.create(); + + assert.throws(() => { + myVfs.openSync('/nonexistent.txt'); + }, { code: 'ENOENT' }); +} + +// Test openSync with directory +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/mydir', { recursive: true }); + + assert.throws(() => { + myVfs.openSync('/mydir'); + }, { code: 'EISDIR' }); +} + +// Test closeSync with invalid fd +{ + const myVfs = vfs.create(); + + assert.throws(() => { + myVfs.closeSync(12345); + }, { code: 'EBADF' }); +} + +// Test readSync +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'hello world'); + + const fd = myVfs.openSync('/file.txt'); + const buffer = Buffer.alloc(5); + + const bytesRead = myVfs.readSync(fd, buffer, 0, 5, 0); + assert.strictEqual(bytesRead, 5); + assert.strictEqual(buffer.toString(), 'hello'); + + myVfs.closeSync(fd); +} + +// Test readSync with position tracking +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'hello world'); + + const fd = myVfs.openSync('/file.txt'); + const buffer1 = Buffer.alloc(5); + const buffer2 = Buffer.alloc(6); + + // Read first 5 bytes + let bytesRead = myVfs.readSync(fd, buffer1, 0, 5, null); + assert.strictEqual(bytesRead, 5); + assert.strictEqual(buffer1.toString(), 'hello'); + + // Continue reading (position should advance) + bytesRead = myVfs.readSync(fd, buffer2, 0, 6, null); + assert.strictEqual(bytesRead, 6); + assert.strictEqual(buffer2.toString(), ' world'); + + myVfs.closeSync(fd); +} + +// Test readSync with explicit position +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'hello world'); + + const fd = myVfs.openSync('/file.txt'); + const buffer = Buffer.alloc(5); + + // Read from position 6 (start of "world") + const bytesRead = myVfs.readSync(fd, buffer, 0, 5, 6); + assert.strictEqual(bytesRead, 5); + assert.strictEqual(buffer.toString(), 'world'); + + myVfs.closeSync(fd); +} + +// Test readSync at end of file +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'short'); + + const fd = myVfs.openSync('/file.txt'); + const buffer = Buffer.alloc(10); + + // Read from position beyond file + const bytesRead = myVfs.readSync(fd, buffer, 0, 10, 100); + assert.strictEqual(bytesRead, 0); + + myVfs.closeSync(fd); +} + +// Test readSync with invalid fd +{ + const myVfs = vfs.create(); + const buffer = Buffer.alloc(10); + + assert.throws(() => { + myVfs.readSync(99999, buffer, 0, 10, 0); + }, { code: 'EBADF' }); +} + +// Test fstatSync +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'hello world'); + + const fd = myVfs.openSync('/file.txt'); + const stats = myVfs.fstatSync(fd); + + assert.strictEqual(stats.isFile(), true); + assert.strictEqual(stats.isDirectory(), false); + assert.strictEqual(stats.size, 11); + + myVfs.closeSync(fd); +} + +// Test fstatSync with invalid fd +{ + const myVfs = vfs.create(); + + assert.throws(() => { + myVfs.fstatSync(99999); + }, { code: 'EBADF' }); +} + +// Test async open and close +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/async-file.txt', 'async content'); + + myVfs.open('/async-file.txt', common.mustCall((err, fd) => { + assert.strictEqual(err, null); + assert.ok((fd & 0x40000000) !== 0); + + myVfs.close(fd, common.mustCall((err) => { + assert.strictEqual(err, null); + })); + })); +} + +// Test async open with error +{ + const myVfs = vfs.create(); + + myVfs.open('/nonexistent.txt', common.mustCall((err, fd) => { + assert.strictEqual(err.code, 'ENOENT'); + assert.strictEqual(fd, undefined); + })); +} + +// Test async close with invalid fd +{ + const myVfs = vfs.create(); + + myVfs.close(99999, common.mustCall((err) => { + assert.strictEqual(err.code, 'EBADF'); + })); +} + +// Test async read +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/read-test.txt', 'read content'); + + myVfs.open('/read-test.txt', common.mustCall((err, fd) => { + assert.strictEqual(err, null); + + const buffer = Buffer.alloc(4); + myVfs.read(fd, buffer, 0, 4, 0, common.mustCall((err, bytesRead, buf) => { + assert.strictEqual(err, null); + assert.strictEqual(bytesRead, 4); + assert.strictEqual(buf, buffer); + assert.strictEqual(buffer.toString(), 'read'); + + myVfs.close(fd, common.mustCall()); + })); + })); +} + +// Test async read with position tracking +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/track-test.txt', 'ABCDEFGHIJ'); + + myVfs.open('/track-test.txt', common.mustCall((err, fd) => { + assert.strictEqual(err, null); + + const buffer1 = Buffer.alloc(3); + const buffer2 = Buffer.alloc(3); + + myVfs.read(fd, buffer1, 0, 3, null, common.mustCall((err, bytesRead, buf) => { + assert.strictEqual(err, null); + assert.strictEqual(bytesRead, 3); + assert.strictEqual(buffer1.toString(), 'ABC'); + + // Continue reading without explicit position + myVfs.read(fd, buffer2, 0, 3, null, common.mustCall((err, bytesRead, buf) => { + assert.strictEqual(err, null); + assert.strictEqual(bytesRead, 3); + assert.strictEqual(buffer2.toString(), 'DEF'); + + myVfs.close(fd, common.mustCall()); + })); + })); + })); +} + +// Test async read with invalid fd +{ + const myVfs = vfs.create(); + const buffer = Buffer.alloc(10); + + myVfs.read(99999, buffer, 0, 10, 0, common.mustCall((err, bytesRead, buf) => { + assert.strictEqual(err.code, 'EBADF'); + })); +} + +// Test async fstat +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/fstat-test.txt', '12345'); + + myVfs.open('/fstat-test.txt', common.mustCall((err, fd) => { + assert.strictEqual(err, null); + + myVfs.fstat(fd, common.mustCall((err, stats) => { + assert.strictEqual(err, null); + assert.strictEqual(stats.isFile(), true); + assert.strictEqual(stats.size, 5); + + myVfs.close(fd, common.mustCall()); + })); + })); +} + +// Test async fstat with invalid fd +{ + const myVfs = vfs.create(); + + myVfs.fstat(99999, common.mustCall((err, stats) => { + assert.strictEqual(err.code, 'EBADF'); + })); +} + +// Test that separate VFS instances have separate fd spaces +{ + const vfs1 = vfs.create(); + const vfs2 = vfs.create(); + + vfs1.writeFileSync('/file1.txt', 'content1'); + vfs2.writeFileSync('/file2.txt', 'content2'); + + const fd1 = vfs1.openSync('/file1.txt'); + const fd2 = vfs2.openSync('/file2.txt'); + + // Both should get valid fds + assert.ok((fd1 & 0x40000000) !== 0); + assert.ok((fd2 & 0x40000000) !== 0); + + // Read from fd1 using vfs1 + const buf1 = Buffer.alloc(8); + const read1 = vfs1.readSync(fd1, buf1, 0, 8, 0); + assert.strictEqual(read1, 8); + assert.strictEqual(buf1.toString(), 'content1'); + + // Read from fd2 using vfs2 + const buf2 = Buffer.alloc(8); + const read2 = vfs2.readSync(fd2, buf2, 0, 8, 0); + assert.strictEqual(read2, 8); + assert.strictEqual(buf2.toString(), 'content2'); + + vfs1.closeSync(fd1); + vfs2.closeSync(fd2); +} + +// Test multiple opens of same file +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/multi.txt', 'multi content'); + + const fd1 = myVfs.openSync('/multi.txt'); + const fd2 = myVfs.openSync('/multi.txt'); + + assert.notStrictEqual(fd1, fd2); + + const buf1 = Buffer.alloc(5); + const buf2 = Buffer.alloc(5); + + myVfs.readSync(fd1, buf1, 0, 5, 0); + myVfs.readSync(fd2, buf2, 0, 5, 0); + + assert.strictEqual(buf1.toString(), 'multi'); + assert.strictEqual(buf2.toString(), 'multi'); + + myVfs.closeSync(fd1); + myVfs.closeSync(fd2); +} diff --git a/test/parallel/test-vfs-file-handle.js b/test/parallel/test-vfs-file-handle.js new file mode 100644 index 00000000000000..6c653593ce7ea2 --- /dev/null +++ b/test/parallel/test-vfs-file-handle.js @@ -0,0 +1,205 @@ +// Flags: --experimental-vfs +'use strict'; + +// Exercise VirtualFileHandle / MemoryFileHandle methods directly via +// the promises.open() handle returned by VFS. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'hello world'); + +(async () => { + // Open file via provider directly (returns a real FileHandle) + const handle = await myVfs.provider.open('/file.txt', 'r'); + assert.ok(handle); + assert.strictEqual(handle.path, '/file.txt'); + assert.strictEqual(handle.flags, 'r'); + assert.strictEqual(typeof handle.mode, 'number'); + assert.strictEqual(handle.closed, false); + + // readFile + const content = await handle.readFile('utf8'); + assert.strictEqual(content, 'hello world'); + + // stat + const stats = await handle.stat(); + assert.strictEqual(stats.size, 11); + + // read into buffer + const buf = Buffer.alloc(5); + const { bytesRead } = await handle.read(buf, 0, 5, 0); + assert.strictEqual(bytesRead, 5); + assert.strictEqual(buf.toString(), 'hello'); + + // readv + const b1 = Buffer.alloc(5); + const b2 = Buffer.alloc(6); + const readvResult = await handle.readv([b1, b2], 0); + assert.strictEqual(readvResult.bytesRead, 11); + assert.strictEqual(b1.toString(), 'hello'); + assert.strictEqual(b2.toString(), ' world'); + + // no-op metadata methods + await handle.chmod(); + await handle.chown(); + await handle.utimes(); + await handle.datasync(); + await handle.sync(); + + await handle.close(); + assert.strictEqual(handle.closed, true); +})().then(common.mustCall()); + +// Write mode: truncate, write, writev, appendFile, truncate +(async () => { + const handle = await myVfs.provider.open('/out.txt', 'w+'); + + // No explicit position so the handle position advances naturally + await handle.write(Buffer.from('hello'), 0, 5); + await handle.writev([Buffer.from(' '), Buffer.from('world')]); + + const stats = await handle.stat(); + assert.strictEqual(stats.size, 11); + + await handle.appendFile('!'); + const content = await handle.readFile('utf8'); + assert.strictEqual(content, 'hello world!'); + + await handle.truncate(5); + const truncated = await handle.readFile('utf8'); + assert.strictEqual(truncated, 'hello'); + + await handle.close(); +})().then(common.mustCall()); + +// readSync / writeSync / readFileSync / writeFileSync / statSync / truncateSync / closeSync +{ + const fd = myVfs.openSync('/sync.txt', 'w'); + const buf = Buffer.from('abc'); + myVfs.writeSync(fd, buf, 0, 3, 0); + myVfs.closeSync(fd); + + const fd2 = myVfs.openSync('/sync.txt', 'r'); + const out = Buffer.alloc(3); + myVfs.readSync(fd2, out, 0, 3, 0); + assert.strictEqual(out.toString(), 'abc'); + const stats = myVfs.fstatSync(fd2); + assert.strictEqual(stats.size, 3); + myVfs.closeSync(fd2); +} + +// using-style explicit resource management for handles +(async () => { + const handle = await myVfs.provider.open('/file.txt', 'r'); + await handle[Symbol.asyncDispose](); + assert.strictEqual(handle.closed, true); +})().then(common.mustCall()); + +// readableWebStream / readLines / createReadStream / createWriteStream throw +(async () => { + const handle = await myVfs.provider.open('/file.txt', 'r'); + assert.throws(() => handle.readableWebStream(), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }); + assert.throws(() => handle.readLines(), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }); + assert.throws(() => handle.createReadStream(), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }); + assert.throws(() => handle.createWriteStream(), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }); + await handle.close(); +})().then(common.mustCall()); + +// Operations after close throw EBADF +(async () => { + const handle = await myVfs.provider.open('/file.txt', 'r'); + await handle.close(); + await assert.rejects(handle.read(Buffer.alloc(1), 0, 1, 0), + { code: 'EBADF' }); + assert.throws(() => handle.readSync(Buffer.alloc(1), 0, 1, 0), + { code: 'EBADF' }); + await assert.rejects(handle.stat(), { code: 'EBADF' }); +})().then(common.mustCall()); + +// Readv with a partial read at EOF (second buffer larger than remaining) +(async () => { + const handle = await myVfs.provider.open('/file.txt', 'r'); + const b1 = Buffer.alloc(5); + const b2 = Buffer.alloc(20); + const r = await handle.readv([b1, b2], 0); + assert.strictEqual(b1.toString(), 'hello'); + assert.strictEqual(r.bytesRead, 11); + await handle.close(); +})().then(common.mustCall()); + +// Writev with explicit position 0 +(async () => { + const wh = await myVfs.provider.open('/wv.txt', 'w+'); + await wh.writev([Buffer.from('AB'), Buffer.from('CD')], 0); + await wh.close(); + assert.strictEqual(myVfs.readFileSync('/wv.txt', 'utf8'), 'ABCD'); +})().then(common.mustCall()); + +// appendFile with string + encoding option +(async () => { + const ah = await myVfs.provider.open('/ap.txt', 'a+'); + await ah.appendFile('hello', { encoding: 'utf8' }); + await ah.close(); + assert.strictEqual(myVfs.readFileSync('/ap.txt', 'utf8'), 'hello'); +})().then(common.mustCall()); + +// 'w'-mode handle rejects all read ops with EBADF +(async () => { + const handle = await myVfs.provider.open('/wonly.txt', 'w'); + assert.throws(() => handle.readSync(Buffer.alloc(1), 0, 1, 0), + { code: 'EBADF' }); + await assert.rejects(handle.read(Buffer.alloc(1), 0, 1, 0), + { code: 'EBADF' }); + assert.throws(() => handle.readFileSync(), { code: 'EBADF' }); + await assert.rejects(handle.readFile(), { code: 'EBADF' }); + await handle.close(); +})().then(common.mustCall()); + +// 'r'-mode handle rejects all write ops with EBADF +(async () => { + myVfs.writeFileSync('/ronly.txt', 'x'); + const handle = await myVfs.provider.open('/ronly.txt', 'r'); + assert.throws(() => handle.writeSync(Buffer.from('y'), 0, 1, 0), + { code: 'EBADF' }); + await assert.rejects(handle.write(Buffer.from('y'), 0, 1, 0), + { code: 'EBADF' }); + assert.throws(() => handle.writeFileSync('y'), { code: 'EBADF' }); + await assert.rejects(handle.writeFile('y'), { code: 'EBADF' }); + assert.throws(() => handle.truncateSync(0), { code: 'EBADF' }); + await assert.rejects(handle.truncate(0), { code: 'EBADF' }); + await handle.close(); +})().then(common.mustCall()); + +// writeFile with string + encoding +(async () => { + const handle = await myVfs.provider.open('/se.txt', 'w+'); + await handle.writeFile('héllo', { encoding: 'utf8' }); + assert.strictEqual(await handle.readFile('utf8'), 'héllo'); + await handle.close(); +})().then(common.mustCall()); + +// Truncate extending past current size zero-fills +(async () => { + const handle = await myVfs.provider.open('/grow.txt', 'w+'); + await handle.writeFile('abc'); + await handle.truncate(10); + assert.strictEqual((await handle.stat()).size, 10); + assert.strictEqual((await handle.readFile()).length, 10); + await handle.close(); +})().then(common.mustCall()); + +// readv / writev / appendFile on a closed handle reject with EBADF +(async () => { + const handle = await myVfs.provider.open('/file.txt', 'r'); + await handle.close(); + await assert.rejects(handle.readv([Buffer.alloc(1)], 0), { code: 'EBADF' }); + await assert.rejects(handle.writev([Buffer.alloc(1)], 0), { code: 'EBADF' }); + await assert.rejects(handle.appendFile('x'), { code: 'EBADF' }); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-flag.js b/test/parallel/test-vfs-flag.js new file mode 100644 index 00000000000000..e4ad539624fbed --- /dev/null +++ b/test/parallel/test-vfs-flag.js @@ -0,0 +1,50 @@ +'use strict'; + +// node:vfs is gated behind --experimental-vfs. Without the flag the +// module is not exposed; bare `vfs` (without the node: scheme) is also +// blocked. + +require('../common'); +const { spawnSync } = require('child_process'); +const assert = require('assert'); + +// Without the flag, requiring node:vfs throws ERR_UNKNOWN_BUILTIN_MODULE. +{ + const r = spawnSync(process.execPath, [ + '-e', 'require("node:vfs")', + ]); + assert.strictEqual(r.status, 1); + assert.match(r.stderr.toString(), /ERR_UNKNOWN_BUILTIN_MODULE/); +} + +// Without the flag, importing node:vfs throws ERR_UNKNOWN_BUILTIN_MODULE. +{ + const r = spawnSync(process.execPath, [ + '--input-type=module', + '-e', 'import("node:vfs").catch((e) => { console.error(e.code); process.exit(1); });', + ]); + assert.strictEqual(r.status, 1); + assert.match(r.stderr.toString(), /ERR_UNKNOWN_BUILTIN_MODULE/); +} + +// With the flag, node:vfs loads and works. +{ + const script = + 'const v = require("node:vfs");' + + 'const x = v.create();' + + 'x.writeFileSync("/x", "hi");' + + 'console.log(x.readFileSync("/x", "utf8"));'; + const r = spawnSync(process.execPath, ['--experimental-vfs', '-e', script]); + assert.strictEqual(r.status, 0, r.stderr.toString()); + assert.strictEqual(r.stdout.toString().trim(), 'hi'); +} + +// Bare `vfs` (no node: scheme) is always blocked. +{ + const r = spawnSync(process.execPath, [ + '--experimental-vfs', + '-e', "require('vfs')", + ]); + assert.strictEqual(r.status, 1); + assert.match(r.stderr.toString(), /Cannot find module 'vfs'/); +} diff --git a/test/parallel/test-vfs-hardlink-nlink.js b/test/parallel/test-vfs-hardlink-nlink.js new file mode 100644 index 00000000000000..8a58d0e9d59081 --- /dev/null +++ b/test/parallel/test-vfs-hardlink-nlink.js @@ -0,0 +1,32 @@ +// Flags: --experimental-vfs +'use strict'; + +// Test that nlink count is updated correctly when creating/removing hard links. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/src.txt', 'content'); + +// Initially nlink should be 1 +assert.strictEqual(myVfs.statSync('/src.txt').nlink, 1); + +// After hard link, nlink should be 2 on both +myVfs.linkSync('/src.txt', '/link.txt'); +assert.strictEqual(myVfs.statSync('/src.txt').nlink, 2); +assert.strictEqual(myVfs.statSync('/link.txt').nlink, 2); + +// Removing one decrements nlink on the other +myVfs.unlinkSync('/link.txt'); +assert.strictEqual(myVfs.statSync('/src.txt').nlink, 1); + +// promises.link equivalent +(async () => { + const v = vfs.create(); + v.writeFileSync('/a', 'x'); + await v.promises.link('/a', '/b'); + assert.strictEqual(v.statSync('/a').nlink, 2); + assert.strictEqual(v.statSync('/b').nlink, 2); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-link.js b/test/parallel/test-vfs-link.js new file mode 100644 index 00000000000000..6925ad004fd966 --- /dev/null +++ b/test/parallel/test-vfs-link.js @@ -0,0 +1,24 @@ +// Flags: --experimental-vfs +'use strict'; + +// Hard-link error cases: creating a link to a directory or to an +// already-existing path. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Linking to a directory throws EINVAL +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d'); + assert.throws(() => myVfs.linkSync('/d', '/d-link'), { code: 'EINVAL' }); +} + +// Linking to an existing target throws EEXIST +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/a.txt', 'x'); + myVfs.writeFileSync('/b.txt', 'y'); + assert.throws(() => myVfs.linkSync('/a.txt', '/b.txt'), { code: 'EEXIST' }); +} diff --git a/test/parallel/test-vfs-memory-file-handle.js b/test/parallel/test-vfs-memory-file-handle.js new file mode 100644 index 00000000000000..5a00b437a3379e --- /dev/null +++ b/test/parallel/test-vfs-memory-file-handle.js @@ -0,0 +1,15 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +// MemoryFileHandle internals: the "stats not available" path when there +// is no entry/getStats callback wired up. + +require('../common'); +const assert = require('assert'); +const { MemoryFileHandle } = require('internal/vfs/file_handle'); + +// MemoryFileHandle without a #getStats callback throws ERR_INVALID_STATE +{ + const h = new MemoryFileHandle('/x', 'r', 0o644, Buffer.alloc(0), null, undefined); + assert.throws(() => h.statSync(), { code: 'ERR_INVALID_STATE' }); +} diff --git a/test/parallel/test-vfs-memory-provider-dynamic.js b/test/parallel/test-vfs-memory-provider-dynamic.js new file mode 100644 index 00000000000000..40b4c5d9fca32a --- /dev/null +++ b/test/parallel/test-vfs-memory-provider-dynamic.js @@ -0,0 +1,127 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +// MemoryProvider supports dynamic content providers and lazily-populated +// directories internally. These features have no public construction API, +// so we drive them directly through MemoryEntry / MemoryProvider. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); +const { MemoryProvider } = require('internal/vfs/providers/memory'); + +function getRoot(provider) { + const symbols = Object.getOwnPropertySymbols(provider); + const kRoot = symbols.find((s) => s.description === 'kRoot'); + assert.ok(kRoot, 'kRoot symbol expected on MemoryProvider'); + return provider[kRoot]; +} + +function makeFileEntry(prototypeFrom, contentProvider) { + const t = Date.now(); + const fileEntry = { __proto__: Object.getPrototypeOf(prototypeFrom) }; + Object.assign(fileEntry, { + type: 0, // TYPE_FILE + mode: 0o644, + content: Buffer.alloc(0), + contentProvider, + children: null, + target: null, + populate: null, + populated: true, + nlink: 1, + uid: 0, + gid: 0, + atime: t, + mtime: t, + ctime: t, + birthtime: t, + }); + fileEntry.isFile = prototypeFrom.isFile.bind(fileEntry); + fileEntry.isDirectory = prototypeFrom.isDirectory.bind(fileEntry); + fileEntry.isSymbolicLink = prototypeFrom.isSymbolicLink.bind(fileEntry); + fileEntry.isDynamic = prototypeFrom.isDynamic.bind(fileEntry); + fileEntry.getContentSync = prototypeFrom.getContentSync.bind(fileEntry); + fileEntry.getContentAsync = prototypeFrom.getContentAsync.bind(fileEntry); + return fileEntry; +} + +// ===== Lazy-populated directory ===== +{ + const provider = new MemoryProvider(); + const root = getRoot(provider); + + const dir = { __proto__: Object.getPrototypeOf(root) }; + Object.assign(dir, { + type: 1, // TYPE_DIR + mode: 0o755, + children: new Map(), + populate: (scoped) => { + scoped.addFile('hello.txt', 'lazy hello'); + scoped.addFile('dyn.txt', () => 'dynamic-string'); + scoped.addDirectory('subdir', null); + scoped.addSymlink('link.txt', '/lazy/hello.txt'); + }, + populated: false, + nlink: 1, + uid: 0, + gid: 0, + }); + const t = Date.now(); + dir.atime = t; dir.mtime = t; dir.ctime = t; dir.birthtime = t; + dir.isFile = root.isFile.bind(dir); + dir.isDirectory = root.isDirectory.bind(dir); + dir.isSymbolicLink = root.isSymbolicLink.bind(dir); + dir.isDynamic = root.isDynamic.bind(dir); + dir.getContentSync = root.getContentSync.bind(dir); + dir.getContentAsync = root.getContentAsync.bind(dir); + root.children.set('lazy', dir); + + const myVfs = vfs.create(provider); + + // Reading the lazy directory triggers populate + const entries = myVfs.readdirSync('/lazy'); + assert.deepStrictEqual(entries.sort(), + ['dyn.txt', 'hello.txt', 'link.txt', 'subdir']); + + // Static file in the lazy directory + assert.strictEqual(myVfs.readFileSync('/lazy/hello.txt', 'utf8'), + 'lazy hello'); + + // Dynamic content provider returning a string (sync read) + assert.strictEqual(myVfs.readFileSync('/lazy/dyn.txt', 'utf8'), + 'dynamic-string'); + + // Dynamic content provider via promises.readFile + myVfs.promises.readFile('/lazy/dyn.txt', 'utf8').then(common.mustCall((s) => { + assert.strictEqual(s, 'dynamic-string'); + })); +} + +// ===== Dynamic content provider returning a Buffer ===== +{ + const provider = new MemoryProvider(); + const root = getRoot(provider); + root.children.set('buf-dyn.txt', + makeFileEntry(root, () => Buffer.from('buffer-content'))); + + const myVfs = vfs.create(provider); + assert.strictEqual(myVfs.readFileSync('/buf-dyn.txt', 'utf8'), + 'buffer-content'); +} + +// ===== Async-only content provider: sync API throws ERR_INVALID_STATE ===== +{ + const provider = new MemoryProvider(); + const root = getRoot(provider); + root.children.set('async-only.txt', + makeFileEntry(root, async () => 'async-only')); + + const myVfs = vfs.create(provider); + assert.throws(() => myVfs.readFileSync('/async-only.txt'), + { code: 'ERR_INVALID_STATE' }); + + myVfs.promises.readFile('/async-only.txt', 'utf8').then(common.mustCall((s) => { + assert.strictEqual(s, 'async-only'); + })); +} diff --git a/test/parallel/test-vfs-memory-provider-flags.js b/test/parallel/test-vfs-memory-provider-flags.js new file mode 100644 index 00000000000000..08963208278b7a --- /dev/null +++ b/test/parallel/test-vfs-memory-provider-flags.js @@ -0,0 +1,42 @@ +// Flags: --experimental-vfs +'use strict'; + +// MemoryProvider: numeric open flags (mirroring fs.constants.O_*) must be +// normalised to their string equivalents. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const vfs = require('node:vfs'); + +const { O_RDONLY, O_RDWR, O_WRONLY, O_CREAT, O_TRUNC, O_EXCL, O_APPEND } = fs.constants; + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'orig'); + +// O_RDONLY (0) +myVfs.closeSync(myVfs.openSync('/file.txt', O_RDONLY)); + +// O_RDWR ('r+') +myVfs.closeSync(myVfs.openSync('/file.txt', O_RDWR)); + +// 'w' = O_WRONLY | O_CREAT | O_TRUNC +myVfs.closeSync(myVfs.openSync('/created.txt', O_WRONLY | O_CREAT | O_TRUNC)); + +// 'wx' = O_WRONLY | O_CREAT | O_EXCL +myVfs.closeSync(myVfs.openSync('/excl.txt', O_WRONLY | O_CREAT | O_EXCL)); + +// 'wx' on an existing file throws EEXIST +assert.throws( + () => myVfs.openSync('/file.txt', O_WRONLY | O_CREAT | O_EXCL), + { code: 'EEXIST' }); + +// 'a' = O_APPEND | O_RDWR | O_CREAT (mapped to 'a+') +myVfs.closeSync(myVfs.openSync('/app.txt', O_APPEND | O_RDWR | O_CREAT)); + +// 'ax+' = O_APPEND | O_EXCL | O_RDWR | O_CREAT +myVfs.closeSync(myVfs.openSync('/axplus.txt', + O_APPEND | O_EXCL | O_RDWR | O_CREAT)); + +// Bogus non-string non-number defaults to 'r' +myVfs.closeSync(myVfs.openSync('/file.txt', null)); diff --git a/test/parallel/test-vfs-memory-provider.js b/test/parallel/test-vfs-memory-provider.js new file mode 100644 index 00000000000000..c45ee69f679b1c --- /dev/null +++ b/test/parallel/test-vfs-memory-provider.js @@ -0,0 +1,664 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Test MemoryProvider can be instantiated directly +{ + const provider = new vfs.MemoryProvider(); + assert.strictEqual(provider.readonly, false); + assert.strictEqual(provider.supportsSymlinks, true); +} + +// Test creating VFS with MemoryProvider (default) +{ + const myVfs = vfs.create(); + assert.ok(myVfs); + assert.strictEqual(myVfs.readonly, false); + assert.ok(myVfs.provider instanceof vfs.MemoryProvider); +} + +// Test creating VFS with explicit MemoryProvider +{ + const myVfs = vfs.create(new vfs.MemoryProvider()); + assert.ok(myVfs); + assert.strictEqual(myVfs.readonly, false); +} + +// Test reading and writing files +{ + const myVfs = vfs.create(); + + // Write a file + myVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); + + // Read it back + assert.strictEqual(myVfs.readFileSync('/hello.txt', 'utf8'), 'Hello from VFS!'); + + // Read as Buffer + const buf = myVfs.readFileSync('/hello.txt'); + assert.ok(Buffer.isBuffer(buf)); + assert.strictEqual(buf.toString(), 'Hello from VFS!'); + + // Overwrite + myVfs.writeFileSync('/hello.txt', 'Overwritten'); + assert.strictEqual(myVfs.readFileSync('/hello.txt', 'utf8'), 'Overwritten'); +} + +// Test appendFile +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/append.txt', 'start'); + myVfs.appendFileSync('/append.txt', '-end'); + assert.strictEqual(myVfs.readFileSync('/append.txt', 'utf8'), 'start-end'); + + // Append to non-existent file creates it + myVfs.appendFileSync('/new-append.txt', 'new content'); + assert.strictEqual(myVfs.readFileSync('/new-append.txt', 'utf8'), 'new content'); +} + +// Test stat operations +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/stat-test.txt', 'content'); + myVfs.mkdirSync('/stat-dir', { recursive: true }); + + const fileStat = myVfs.statSync('/stat-test.txt'); + assert.strictEqual(fileStat.isFile(), true); + assert.strictEqual(fileStat.isDirectory(), false); + assert.strictEqual(fileStat.size, 7); + + const dirStat = myVfs.statSync('/stat-dir'); + assert.strictEqual(dirStat.isFile(), false); + assert.strictEqual(dirStat.isDirectory(), true); + + // Test ENOENT + assert.throws(() => { + myVfs.statSync('/nonexistent'); + }, { code: 'ENOENT' }); +} + +// Test lstatSync (same as statSync for memory provider since no real symlink following) +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/lstat.txt', 'lstat test'); + + const stat = myVfs.lstatSync('/lstat.txt'); + assert.strictEqual(stat.isFile(), true); +} + +// Test readdirSync +{ + const myVfs = vfs.create(); + + myVfs.mkdirSync('/readdir-test/subdir', { recursive: true }); + myVfs.writeFileSync('/readdir-test/a.txt', 'a'); + myVfs.writeFileSync('/readdir-test/b.txt', 'b'); + + const entries = myVfs.readdirSync('/readdir-test'); + assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt', 'subdir']); + + // With file types + const dirents = myVfs.readdirSync('/readdir-test', { withFileTypes: true }); + assert.strictEqual(dirents.length, 3); + + const fileEntry = dirents.find((d) => d.name === 'a.txt'); + assert.ok(fileEntry); + assert.strictEqual(fileEntry.isFile(), true); + + const dirEntry = dirents.find((d) => d.name === 'subdir'); + assert.ok(dirEntry); + assert.strictEqual(dirEntry.isDirectory(), true); + + // ENOENT for non-existent directory + assert.throws(() => { + myVfs.readdirSync('/nonexistent'); + }, { code: 'ENOENT' }); + + // ENOTDIR for file + assert.throws(() => { + myVfs.readdirSync('/readdir-test/a.txt'); + }, { code: 'ENOTDIR' }); +} + +// Test mkdir and rmdir +{ + const myVfs = vfs.create(); + + myVfs.mkdirSync('/new-dir'); + assert.strictEqual(myVfs.existsSync('/new-dir'), true); + assert.strictEqual(myVfs.statSync('/new-dir').isDirectory(), true); + + myVfs.rmdirSync('/new-dir'); + assert.strictEqual(myVfs.existsSync('/new-dir'), false); + + // EEXIST for existing directory + myVfs.mkdirSync('/exists'); + assert.throws(() => { + myVfs.mkdirSync('/exists'); + }, { code: 'EEXIST' }); + + // ENOTEMPTY for non-empty directory + myVfs.mkdirSync('/nonempty'); + myVfs.writeFileSync('/nonempty/file.txt', 'content'); + assert.throws(() => { + myVfs.rmdirSync('/nonempty'); + }, { code: 'ENOTEMPTY' }); +} + +// Test recursive mkdir +{ + const myVfs = vfs.create(); + + myVfs.mkdirSync('/deep/nested/dir', { recursive: true }); + assert.strictEqual(myVfs.existsSync('/deep/nested/dir'), true); + assert.strictEqual(myVfs.statSync('/deep/nested/dir').isDirectory(), true); + + // Recursive on existing is OK + myVfs.mkdirSync('/deep/nested/dir', { recursive: true }); + assert.strictEqual(myVfs.existsSync('/deep/nested/dir'), true); +} + +// Test unlink +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/to-delete.txt', 'delete me'); + assert.strictEqual(myVfs.existsSync('/to-delete.txt'), true); + + myVfs.unlinkSync('/to-delete.txt'); + assert.strictEqual(myVfs.existsSync('/to-delete.txt'), false); + + // ENOENT for non-existent file + assert.throws(() => { + myVfs.unlinkSync('/nonexistent.txt'); + }, { code: 'ENOENT' }); + + // EISDIR for directory + myVfs.mkdirSync('/dir-to-unlink'); + assert.throws(() => { + myVfs.unlinkSync('/dir-to-unlink'); + }, { code: 'EISDIR' }); +} + +// Test rename +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/old-name.txt', 'rename me'); + myVfs.renameSync('/old-name.txt', '/new-name.txt'); + + assert.strictEqual(myVfs.existsSync('/old-name.txt'), false); + assert.strictEqual(myVfs.existsSync('/new-name.txt'), true); + assert.strictEqual(myVfs.readFileSync('/new-name.txt', 'utf8'), 'rename me'); + + // ENOENT for non-existent source + assert.throws(() => { + myVfs.renameSync('/nonexistent.txt', '/dest.txt'); + }, { code: 'ENOENT' }); +} + +// Test copyFile +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/source.txt', 'copy me'); + myVfs.copyFileSync('/source.txt', '/dest.txt'); + + assert.strictEqual(myVfs.existsSync('/source.txt'), true); + assert.strictEqual(myVfs.existsSync('/dest.txt'), true); + assert.strictEqual(myVfs.readFileSync('/dest.txt', 'utf8'), 'copy me'); + + // ENOENT for non-existent source + assert.throws(() => { + myVfs.copyFileSync('/nonexistent.txt', '/fail.txt'); + }, { code: 'ENOENT' }); +} + +// Test realpathSync +{ + const myVfs = vfs.create(); + + myVfs.mkdirSync('/path/to', { recursive: true }); + myVfs.writeFileSync('/path/to/real.txt', 'content'); + + const resolved = myVfs.realpathSync('/path/to/real.txt'); + assert.strictEqual(resolved, '/path/to/real.txt'); + + // With .. components + const normalized = myVfs.realpathSync('/path/to/../to/real.txt'); + assert.strictEqual(normalized, '/path/to/real.txt'); + + // ENOENT for non-existent + assert.throws(() => { + myVfs.realpathSync('/nonexistent'); + }, { code: 'ENOENT' }); +} + +// Test existsSync +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/exists.txt', 'content'); + assert.strictEqual(myVfs.existsSync('/exists.txt'), true); + assert.strictEqual(myVfs.existsSync('/not-exists.txt'), false); +} + +// Test accessSync +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/accessible.txt', 'content'); + + // Should not throw for existing file + myVfs.accessSync('/accessible.txt'); + + // Should throw ENOENT for non-existent + assert.throws(() => { + myVfs.accessSync('/nonexistent.txt'); + }, { code: 'ENOENT' }); +} + +// Test file handle operations via openSync +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/handle-test.txt', 'hello world'); + + const fd = myVfs.openSync('/handle-test.txt', 'r'); + assert.ok((fd & 0x40000000) !== 0); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + // Read via file handle + const buffer = Buffer.alloc(5); + const bytesRead = handle.entry.readSync(buffer, 0, 5, 0); + assert.strictEqual(bytesRead, 5); + assert.strictEqual(buffer.toString(), 'hello'); + + myVfs.closeSync(fd); +} + +// Test file handle write operations +{ + const myVfs = vfs.create(); + + const fd = myVfs.openSync('/write-handle.txt', 'w'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + const buffer = Buffer.from('written via handle'); + const bytesWritten = handle.entry.writeSync(buffer, 0, buffer.length, 0); + assert.strictEqual(bytesWritten, buffer.length); + + myVfs.closeSync(fd); + + // Verify content + assert.strictEqual(myVfs.readFileSync('/write-handle.txt', 'utf8'), 'written via handle'); +} + +// Test file handle readFile and writeFile +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/handle-rw.txt', 'original'); + + const fd = myVfs.openSync('/handle-rw.txt', 'r+'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + // Read via readFile + const content = handle.entry.readFileSync('utf8'); + assert.strictEqual(content, 'original'); + + // Write via writeFile + handle.entry.writeFileSync('replaced'); + myVfs.closeSync(fd); + + assert.strictEqual(myVfs.readFileSync('/handle-rw.txt', 'utf8'), 'replaced'); +} + +// Test symlink operations +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/target.txt', 'target content'); + myVfs.symlinkSync('/target.txt', '/link.txt'); + + // Reading through symlink should work + assert.strictEqual(myVfs.readFileSync('/link.txt', 'utf8'), 'target content'); + + // ReadlinkSync should return target + assert.strictEqual(myVfs.readlinkSync('/link.txt'), '/target.txt'); + + // Lstat on symlink should show it's a symlink + const lstat = myVfs.lstatSync('/link.txt'); + assert.strictEqual(lstat.isSymbolicLink(), true); +} + +// Test reading directory as file should fail +{ + const myVfs = vfs.create(); + + myVfs.mkdirSync('/mydir', { recursive: true }); + assert.throws(() => { + myVfs.readFileSync('/mydir'); + }, { code: 'EISDIR' }); +} + +// Test that readFileSync returns independent buffer copies +{ + const myVfs = vfs.create(); + + myVfs.writeFileSync('/independent.txt', 'original content'); + + const buf1 = myVfs.readFileSync('/independent.txt'); + const buf2 = myVfs.readFileSync('/independent.txt'); + + // Both should have the same content + assert.deepStrictEqual(buf1, buf2); + + // Mutating one should not affect the other + buf1[0] = 0xFF; + assert.notDeepStrictEqual(buf1, buf2); + assert.strictEqual(buf2.toString(), 'original content'); + + // A third read should still return the original content + const buf3 = myVfs.readFileSync('/independent.txt'); + assert.strictEqual(buf3.toString(), 'original content'); +} + +// ==================== Async Operations ==================== + +// Test async read and write operations +(async () => { + const myVfs = vfs.create(); + + await myVfs.promises.writeFile('/async-test.txt', 'async content'); + const content = await myVfs.promises.readFile('/async-test.txt', 'utf8'); + assert.strictEqual(content, 'async content'); + + const stat = await myVfs.promises.stat('/async-test.txt'); + assert.strictEqual(stat.isFile(), true); + + await myVfs.promises.unlink('/async-test.txt'); + assert.strictEqual(myVfs.existsSync('/async-test.txt'), false); +})().then(common.mustCall()); + +// Test async lstat +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/async-lstat.txt', 'async lstat'); + + const stat = await myVfs.promises.lstat('/async-lstat.txt'); + assert.strictEqual(stat.isFile(), true); +})().then(common.mustCall()); + +// Test async copyFile +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/async-src.txt', 'async copy'); + + await myVfs.promises.copyFile('/async-src.txt', '/async-dest.txt'); + + assert.strictEqual(myVfs.existsSync('/async-dest.txt'), true); + assert.strictEqual(myVfs.readFileSync('/async-dest.txt', 'utf8'), 'async copy'); +})().then(common.mustCall()); + +// Test async mkdir and rmdir +(async () => { + const myVfs = vfs.create(); + + await myVfs.promises.mkdir('/async-dir'); + assert.strictEqual(myVfs.existsSync('/async-dir'), true); + + await myVfs.promises.rmdir('/async-dir'); + assert.strictEqual(myVfs.existsSync('/async-dir'), false); +})().then(common.mustCall()); + +// Test async rename +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/async-old.txt', 'async rename'); + + await myVfs.promises.rename('/async-old.txt', '/async-new.txt'); + + assert.strictEqual(myVfs.existsSync('/async-old.txt'), false); + assert.strictEqual(myVfs.existsSync('/async-new.txt'), true); +})().then(common.mustCall()); + +// Test async readdir +(async () => { + const myVfs = vfs.create(); + + myVfs.mkdirSync('/async-readdir', { recursive: true }); + myVfs.writeFileSync('/async-readdir/file.txt', 'content'); + + const entries = await myVfs.promises.readdir('/async-readdir'); + assert.deepStrictEqual(entries, ['file.txt']); +})().then(common.mustCall()); + +// Test async appendFile +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/async-append.txt', 'start'); + + await myVfs.promises.appendFile('/async-append.txt', '-end'); + assert.strictEqual(myVfs.readFileSync('/async-append.txt', 'utf8'), 'start-end'); +})().then(common.mustCall()); + +// Test async access +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/async-access.txt', 'content'); + + await myVfs.promises.access('/async-access.txt'); + + await assert.rejects( + myVfs.promises.access('/nonexistent.txt'), + { code: 'ENOENT' } + ); +})().then(common.mustCall()); + +// Test async realpath +(async () => { + const myVfs = vfs.create(); + + myVfs.mkdirSync('/async-real/path', { recursive: true }); + myVfs.writeFileSync('/async-real/path/file.txt', 'content'); + + const resolved = await myVfs.promises.realpath('/async-real/path/file.txt'); + assert.strictEqual(resolved, '/async-real/path/file.txt'); +})().then(common.mustCall()); + +// Test async file handle read +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/async-handle.txt', 'async read test'); + + const fd = myVfs.openSync('/async-handle.txt', 'r'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + const buffer = Buffer.alloc(10); + const result = await handle.entry.read(buffer, 0, 10, 0); + assert.strictEqual(result.bytesRead, 10); + assert.strictEqual(buffer.toString(), 'async read'); + + myVfs.closeSync(fd); +})().then(common.mustCall()); + +// Test async file handle write +(async () => { + const myVfs = vfs.create(); + + const fd = myVfs.openSync('/async-write.txt', 'w'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + const buffer = Buffer.from('async write'); + const result = await handle.entry.write(buffer, 0, buffer.length, 0); + assert.strictEqual(result.bytesWritten, buffer.length); + + myVfs.closeSync(fd); + + // Verify content + assert.strictEqual(myVfs.readFileSync('/async-write.txt', 'utf8'), 'async write'); +})().then(common.mustCall()); + +// Test async file handle stat +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/stat-handle.txt', 'stat test'); + + const fd = myVfs.openSync('/stat-handle.txt', 'r'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + const stat = await handle.entry.stat(); + assert.strictEqual(stat.isFile(), true); + + myVfs.closeSync(fd); +})().then(common.mustCall()); + +// Test async file handle truncate +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/truncate-handle.txt', 'truncate this'); + + const fd = myVfs.openSync('/truncate-handle.txt', 'r+'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + await handle.entry.truncate(8); + myVfs.closeSync(fd); + + // Verify content was truncated + assert.strictEqual(myVfs.readFileSync('/truncate-handle.txt', 'utf8'), 'truncate'); +})().then(common.mustCall()); + +// Test async file handle close +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/close-handle.txt', 'close test'); + + const fd = myVfs.openSync('/close-handle.txt', 'r'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + await handle.entry.close(); + assert.strictEqual(handle.entry.closed, true); +})().then(common.mustCall()); + +// Test async readFile and writeFile on handle +(async () => { + const myVfs = vfs.create(); + + myVfs.writeFileSync('/async-rw.txt', 'async original'); + + const fd = myVfs.openSync('/async-rw.txt', 'r+'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + // Async read + const content = await handle.entry.readFile('utf8'); + assert.strictEqual(content, 'async original'); + + // Async write + await handle.entry.writeFile('async replaced'); + myVfs.closeSync(fd); + + assert.strictEqual(myVfs.readFileSync('/async-rw.txt', 'utf8'), 'async replaced'); +})().then(common.mustCall()); + +// ==================== Readonly Mode ==================== + +// Test MemoryProvider readonly mode +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'content'); + myVfs.mkdirSync('/dir', { recursive: true }); + + // Set to readonly + myVfs.provider.setReadOnly(); + assert.strictEqual(myVfs.provider.readonly, true); + + // Read operations should still work + assert.strictEqual(myVfs.readFileSync('/file.txt', 'utf8'), 'content'); + assert.strictEqual(myVfs.existsSync('/file.txt'), true); + assert.ok(myVfs.statSync('/file.txt')); + + // Write operations should throw EROFS + assert.throws(() => { + myVfs.writeFileSync('/file.txt', 'new content'); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.writeFileSync('/new.txt', 'content'); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.appendFileSync('/file.txt', 'more'); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.mkdirSync('/newdir'); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.unlinkSync('/file.txt'); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.rmdirSync('/dir'); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.renameSync('/file.txt', '/renamed.txt'); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.copyFileSync('/file.txt', '/copy.txt'); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.symlinkSync('/file.txt', '/link'); + }, { code: 'EROFS' }); +} + +// Test async operations on readonly MemoryProvider +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/readonly.txt', 'content'); + myVfs.provider.setReadOnly(); + + await assert.rejects( + myVfs.promises.writeFile('/readonly.txt', 'new'), + { code: 'EROFS' } + ); + + await assert.rejects( + myVfs.promises.appendFile('/readonly.txt', 'more'), + { code: 'EROFS' } + ); + + await assert.rejects( + myVfs.promises.mkdir('/newdir'), + { code: 'EROFS' } + ); + + await assert.rejects( + myVfs.promises.unlink('/readonly.txt'), + { code: 'EROFS' } + ); + + await assert.rejects( + myVfs.promises.copyFile('/readonly.txt', '/copy.txt'), + { code: 'EROFS' } + ); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-mkdir.js b/test/parallel/test-vfs-mkdir.js new file mode 100644 index 00000000000000..87a823b77d87ca --- /dev/null +++ b/test/parallel/test-vfs-mkdir.js @@ -0,0 +1,49 @@ +// Flags: --experimental-vfs +'use strict'; + +// mkdirSync / rmdirSync behaviour: return value, recursive option, mode +// option, error cases. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// mkdirSync({ recursive: true }) returns the path of the first newly- +// created directory when some parents already exist. +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/a'); + const result = myVfs.mkdirSync('/a/b/c', { recursive: true }); + assert.strictEqual(result, '/a/b'); +} + +// mkdirSync with explicit mode (non-recursive) +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d-mode', { mode: 0o700 }); + assert.strictEqual(myVfs.statSync('/d-mode').mode & 0o777, 0o700); +} + +// mkdirSync with explicit mode + recursive +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/r-mode/sub/deep', { recursive: true, mode: 0o700 }); + assert.strictEqual(myVfs.statSync('/r-mode/sub/deep').mode & 0o777, 0o700); +} + +// Recursive mkdir through a regular-file blocker throws ENOTDIR +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/blocker', 'x'); + assert.throws( + () => myVfs.mkdirSync('/blocker/sub', { recursive: true }), + { code: 'ENOTDIR' }); +} + +// Rmdir on a non-empty directory throws ENOTEMPTY +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d'); + myVfs.writeFileSync('/d/x', ''); + assert.throws(() => myVfs.rmdirSync('/d'), { code: 'ENOTEMPTY' }); +} diff --git a/test/parallel/test-vfs-mkdtemp.js b/test/parallel/test-vfs-mkdtemp.js new file mode 100644 index 00000000000000..03605333ac343e --- /dev/null +++ b/test/parallel/test-vfs-mkdtemp.js @@ -0,0 +1,40 @@ +// Flags: --experimental-vfs +'use strict'; + +// mkdtemp / mkdtempSync behaviour. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// mkdtempSync returns the created directory path (with random suffix) +{ + const myVfs = vfs.create(); + const dir = myVfs.mkdtempSync('/tmp-'); + assert.ok(dir.startsWith('/tmp-')); + assert.ok(myVfs.statSync(dir).isDirectory()); +} + +// Mkdtemp callback variant - success +{ + const myVfs = vfs.create(); + myVfs.mkdtemp('/tmp-', common.mustSucceed((dir) => { + assert.ok(dir.startsWith('/tmp-')); + })); +} + +// Mkdtemp callback variant - with options object +{ + const myVfs = vfs.create(); + myVfs.mkdtemp('/tmp-', {}, common.mustSucceed((dir) => { + assert.ok(dir.startsWith('/tmp-')); + })); +} + +// Mkdtemp callback variant — error path (parent doesn't exist) +{ + const myVfs = vfs.create(); + myVfs.mkdtemp('/missing/prefix-', common.mustCall((err) => { + assert.ok(err); + })); +} diff --git a/test/parallel/test-vfs-parent-timestamps.js b/test/parallel/test-vfs-parent-timestamps.js new file mode 100644 index 00000000000000..d12b2e7ec54182 --- /dev/null +++ b/test/parallel/test-vfs-parent-timestamps.js @@ -0,0 +1,25 @@ +// Flags: --experimental-vfs +'use strict'; + +// Operations that modify a directory should bump its mtime/ctime. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.mkdirSync('/dir'); + +function getTimestamps(p) { + const st = myVfs.statSync(p); + return { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs }; +} + +const before = getTimestamps('/dir'); +// Wait long enough for ms-resolution mtime to differ +setTimeout(common.mustCall(() => { + myVfs.writeFileSync('/dir/file.txt', 'hello'); + const after = getTimestamps('/dir'); + assert.ok(after.mtimeMs >= before.mtimeMs); + assert.ok(after.ctimeMs >= before.ctimeMs); +}), 5); diff --git a/test/parallel/test-vfs-promises-open.js b/test/parallel/test-vfs-promises-open.js new file mode 100644 index 00000000000000..b993ebc32b11bf --- /dev/null +++ b/test/parallel/test-vfs-promises-open.js @@ -0,0 +1,17 @@ +// Flags: --experimental-vfs +'use strict'; + +// VFS promises.open returns a usable handle. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/hello.txt', 'hello from vfs'); + +(async () => { + const fh = await myVfs.promises.open('/hello.txt', 'r'); + assert.ok(fh); + assert.ok(typeof fh === 'number' || typeof fh === 'object'); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-promises.js b/test/parallel/test-vfs-promises.js new file mode 100644 index 00000000000000..da27dfc0360957 --- /dev/null +++ b/test/parallel/test-vfs-promises.js @@ -0,0 +1,491 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Test callback-based readFile +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/test.txt', 'hello world'); + + myVfs.readFile('/test.txt', common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.ok(Buffer.isBuffer(data)); + assert.strictEqual(data.toString(), 'hello world'); + })); + + myVfs.readFile('/test.txt', 'utf8', common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.strictEqual(data, 'hello world'); + })); + + myVfs.readFile('/test.txt', { encoding: 'utf8' }, common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.strictEqual(data, 'hello world'); + })); +} + +// Test callback-based readFile with non-existent file +{ + const myVfs = vfs.create(); + + myVfs.readFile('/nonexistent.txt', common.mustCall((err, data) => { + assert.strictEqual(err.code, 'ENOENT'); + assert.strictEqual(data, undefined); + })); +} + +// Test callback-based readFile with directory +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/mydir', { recursive: true }); + + myVfs.readFile('/mydir', common.mustCall((err, data) => { + assert.strictEqual(err.code, 'EISDIR'); + assert.strictEqual(data, undefined); + })); +} + +// Test callback-based stat +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir', { recursive: true }); + myVfs.writeFileSync('/file.txt', 'content'); + + myVfs.stat('/file.txt', common.mustCall((err, stats) => { + assert.strictEqual(err, null); + assert.strictEqual(stats.isFile(), true); + assert.strictEqual(stats.isDirectory(), false); + assert.strictEqual(stats.size, 7); + })); + + myVfs.stat('/dir', common.mustCall((err, stats) => { + assert.strictEqual(err, null); + assert.strictEqual(stats.isFile(), false); + assert.strictEqual(stats.isDirectory(), true); + })); + + myVfs.stat('/nonexistent', common.mustCall((err, stats) => { + assert.strictEqual(err.code, 'ENOENT'); + assert.strictEqual(stats, undefined); + })); +} + +// Test callback-based lstat (same as stat for VFS) +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'content'); + + myVfs.lstat('/file.txt', common.mustCall((err, stats) => { + assert.strictEqual(err, null); + assert.strictEqual(stats.isFile(), true); + })); +} + +// Test callback-based readdir +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir/subdir', { recursive: true }); + myVfs.writeFileSync('/dir/file1.txt', 'a'); + myVfs.writeFileSync('/dir/file2.txt', 'b'); + + myVfs.readdir('/dir', common.mustCall((err, entries) => { + assert.strictEqual(err, null); + assert.deepStrictEqual(entries.sort(), ['file1.txt', 'file2.txt', 'subdir']); + })); + + myVfs.readdir('/dir', { withFileTypes: true }, common.mustCall((err, entries) => { + assert.strictEqual(err, null); + assert.strictEqual(entries.length, 3); + + const file1 = entries.find((e) => e.name === 'file1.txt'); + assert.strictEqual(file1.isFile(), true); + assert.strictEqual(file1.isDirectory(), false); + + const subdir = entries.find((e) => e.name === 'subdir'); + assert.strictEqual(subdir.isFile(), false); + assert.strictEqual(subdir.isDirectory(), true); + })); + + myVfs.readdir('/nonexistent', common.mustCall((err, entries) => { + assert.strictEqual(err.code, 'ENOENT'); + assert.strictEqual(entries, undefined); + })); + + myVfs.readdir('/dir/file1.txt', common.mustCall((err, entries) => { + assert.strictEqual(err.code, 'ENOTDIR'); + assert.strictEqual(entries, undefined); + })); +} + +// Test callback-based realpath +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/path/to', { recursive: true }); + myVfs.writeFileSync('/path/to/file.txt', 'content'); + + myVfs.realpath('/path/to/file.txt', common.mustCall((err, resolved) => { + assert.strictEqual(err, null); + assert.strictEqual(resolved, '/path/to/file.txt'); + })); + + myVfs.realpath('/path/to/../to/file.txt', common.mustCall((err, resolved) => { + assert.strictEqual(err, null); + assert.strictEqual(resolved, '/path/to/file.txt'); + })); + + myVfs.realpath('/nonexistent', common.mustCall((err, resolved) => { + assert.strictEqual(err.code, 'ENOENT'); + assert.strictEqual(resolved, undefined); + })); +} + +// Test callback-based access +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/accessible.txt', 'content'); + + myVfs.access('/accessible.txt', common.mustCall((err) => { + assert.strictEqual(err, null); + })); + + myVfs.access('/nonexistent.txt', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); +} + +// Test callback-based writeFile +{ + const myVfs = vfs.create(); + + myVfs.writeFile('/cb-write.txt', 'callback written', common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual(myVfs.readFileSync('/cb-write.txt', 'utf8'), 'callback written'); + })); + + // Overwrite existing + myVfs.writeFileSync('/cb-overwrite.txt', 'old'); + myVfs.writeFile('/cb-overwrite.txt', 'new', common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual(myVfs.readFileSync('/cb-overwrite.txt', 'utf8'), 'new'); + })); + + // Write with Buffer + myVfs.writeFile('/cb-buf.txt', Buffer.from('buf data'), common.mustCall((err) => { + assert.strictEqual(err, null); + assert.strictEqual(myVfs.readFileSync('/cb-buf.txt', 'utf8'), 'buf data'); + })); +} + +// Test callback-based readlink +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/link-target.txt', 'content'); + myVfs.symlinkSync('/link-target.txt', '/my-link.txt'); + + myVfs.readlink('/my-link.txt', common.mustCall((err, target) => { + assert.strictEqual(err, null); + assert.strictEqual(target, '/link-target.txt'); + })); + + myVfs.readlink('/link-target.txt', common.mustCall((err) => { + assert.strictEqual(err.code, 'EINVAL'); + })); +} + +// Test callback-based open, read, fstat, close +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/fd-test.txt', 'fd content'); + + myVfs.open('/fd-test.txt', 'r', common.mustCall((err, fd) => { + assert.strictEqual(err, null); + assert.strictEqual(typeof fd, 'number'); + + // fstat + myVfs.fstat(fd, common.mustCall((err, stats) => { + assert.strictEqual(err, null); + assert.strictEqual(stats.isFile(), true); + assert.strictEqual(stats.size, 10); + })); + + // read + const buf = Buffer.alloc(10); + myVfs.read(fd, buf, 0, 10, 0, common.mustCall((err, bytesRead, buffer) => { + assert.strictEqual(err, null); + assert.strictEqual(bytesRead, 10); + assert.strictEqual(buffer.toString(), 'fd content'); + })); + + // close + myVfs.close(fd, common.mustCall((err) => { + assert.strictEqual(err, null); + })); + })); + + // open non-existent + myVfs.open('/nonexistent.txt', 'r', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); +} + +// ==================== Promise API Tests ==================== + +// Test promises.readFile +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/promise-test.txt', 'promise content'); + + const bufferData = await myVfs.promises.readFile('/promise-test.txt'); + assert.ok(Buffer.isBuffer(bufferData)); + assert.strictEqual(bufferData.toString(), 'promise content'); + + const stringData = await myVfs.promises.readFile('/promise-test.txt', 'utf8'); + assert.strictEqual(stringData, 'promise content'); + + const stringData2 = await myVfs.promises.readFile('/promise-test.txt', { encoding: 'utf8' }); + assert.strictEqual(stringData2, 'promise content'); + + await assert.rejects( + myVfs.promises.readFile('/nonexistent.txt'), + { code: 'ENOENT' } + ); + + myVfs.mkdirSync('/promisedir', { recursive: true }); + await assert.rejects( + myVfs.promises.readFile('/promisedir'), + { code: 'EISDIR' } + ); +})().then(common.mustCall()); + +// Test promises.stat +(async () => { + const myVfs = vfs.create(); + myVfs.mkdirSync('/stat-dir', { recursive: true }); + myVfs.writeFileSync('/stat-file.txt', 'hello'); + + const fileStats = await myVfs.promises.stat('/stat-file.txt'); + assert.strictEqual(fileStats.isFile(), true); + assert.strictEqual(fileStats.size, 5); + + const dirStats = await myVfs.promises.stat('/stat-dir'); + assert.strictEqual(dirStats.isDirectory(), true); + + await assert.rejects( + myVfs.promises.stat('/nonexistent'), + { code: 'ENOENT' } + ); +})().then(common.mustCall()); + +// Test promises.lstat +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/lstat-file.txt', 'content'); + + const stats = await myVfs.promises.lstat('/lstat-file.txt'); + assert.strictEqual(stats.isFile(), true); +})().then(common.mustCall()); + +// Test promises.readdir +(async () => { + const myVfs = vfs.create(); + myVfs.mkdirSync('/pdir/sub', { recursive: true }); + myVfs.writeFileSync('/pdir/a.txt', 'a'); + myVfs.writeFileSync('/pdir/b.txt', 'b'); + + const names = await myVfs.promises.readdir('/pdir'); + assert.deepStrictEqual(names.sort(), ['a.txt', 'b.txt', 'sub']); + + const dirents = await myVfs.promises.readdir('/pdir', { withFileTypes: true }); + assert.strictEqual(dirents.length, 3); + const aFile = dirents.find((e) => e.name === 'a.txt'); + assert.strictEqual(aFile.isFile(), true); + + await assert.rejects( + myVfs.promises.readdir('/nonexistent'), + { code: 'ENOENT' } + ); + + await assert.rejects( + myVfs.promises.readdir('/pdir/a.txt'), + { code: 'ENOTDIR' } + ); +})().then(common.mustCall()); + +// Test promises.realpath +(async () => { + const myVfs = vfs.create(); + myVfs.mkdirSync('/real/path', { recursive: true }); + myVfs.writeFileSync('/real/path/file.txt', 'content'); + + const resolved = await myVfs.promises.realpath('/real/path/file.txt'); + assert.strictEqual(resolved, '/real/path/file.txt'); + + const normalized = await myVfs.promises.realpath('/real/path/../path/file.txt'); + assert.strictEqual(normalized, '/real/path/file.txt'); + + await assert.rejects( + myVfs.promises.realpath('/nonexistent'), + { code: 'ENOENT' } + ); +})().then(common.mustCall()); + +// Test promises.access +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/access-test.txt', 'content'); + + await myVfs.promises.access('/access-test.txt'); + + await assert.rejects( + myVfs.promises.access('/nonexistent'), + { code: 'ENOENT' } + ); +})().then(common.mustCall()); + +// Test promises.writeFile +(async () => { + const myVfs = vfs.create(); + + await myVfs.promises.writeFile('/write-test.txt', 'async written'); + assert.strictEqual(myVfs.readFileSync('/write-test.txt', 'utf8'), 'async written'); + + // Overwrite existing file + await myVfs.promises.writeFile('/write-test.txt', 'overwritten'); + assert.strictEqual(myVfs.readFileSync('/write-test.txt', 'utf8'), 'overwritten'); + + // Write with Buffer + await myVfs.promises.writeFile('/buffer-write.txt', Buffer.from('buffer data')); + assert.strictEqual(myVfs.readFileSync('/buffer-write.txt', 'utf8'), 'buffer data'); +})().then(common.mustCall()); + +// Test promises.appendFile +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/append-test.txt', 'start'); + + await myVfs.promises.appendFile('/append-test.txt', '-end'); + assert.strictEqual(myVfs.readFileSync('/append-test.txt', 'utf8'), 'start-end'); + + // Append to non-existent file creates it + await myVfs.promises.appendFile('/new-append.txt', 'new content'); + assert.strictEqual(myVfs.readFileSync('/new-append.txt', 'utf8'), 'new content'); +})().then(common.mustCall()); + +// Test promises.mkdir +(async () => { + const myVfs = vfs.create(); + + await myVfs.promises.mkdir('/async-dir'); + const stat = myVfs.statSync('/async-dir'); + assert.strictEqual(stat.isDirectory(), true); + + // Recursive mkdir + await myVfs.promises.mkdir('/async-dir/nested/deep', { recursive: true }); + assert.strictEqual(myVfs.statSync('/async-dir/nested/deep').isDirectory(), true); + + // Mkdir on existing directory throws without recursive + await assert.rejects( + myVfs.promises.mkdir('/async-dir'), + { code: 'EEXIST' } + ); +})().then(common.mustCall()); + +// Test promises.unlink +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/unlink-test.txt', 'to delete'); + + await myVfs.promises.unlink('/unlink-test.txt'); + assert.strictEqual(myVfs.existsSync('/unlink-test.txt'), false); + + await assert.rejects( + myVfs.promises.unlink('/nonexistent.txt'), + { code: 'ENOENT' } + ); +})().then(common.mustCall()); + +// Test promises.rmdir +(async () => { + const myVfs = vfs.create(); + myVfs.mkdirSync('/rmdir-test'); + + await myVfs.promises.rmdir('/rmdir-test'); + assert.strictEqual(myVfs.existsSync('/rmdir-test'), false); + + // Rmdir on non-empty directory throws + myVfs.mkdirSync('/nonempty'); + myVfs.writeFileSync('/nonempty/file.txt', 'content'); + await assert.rejects( + myVfs.promises.rmdir('/nonempty'), + { code: 'ENOTEMPTY' } + ); +})().then(common.mustCall()); + +// Test promises.rename +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/rename-src.txt', 'rename me'); + + await myVfs.promises.rename('/rename-src.txt', '/rename-dest.txt'); + assert.strictEqual(myVfs.existsSync('/rename-src.txt'), false); + assert.strictEqual(myVfs.readFileSync('/rename-dest.txt', 'utf8'), 'rename me'); +})().then(common.mustCall()); + +// Test promises.copyFile +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/copy-src.txt', 'copy me'); + + await myVfs.promises.copyFile('/copy-src.txt', '/copy-dest.txt'); + assert.strictEqual(myVfs.readFileSync('/copy-dest.txt', 'utf8'), 'copy me'); + // Source still exists + assert.strictEqual(myVfs.existsSync('/copy-src.txt'), true); + + await assert.rejects( + myVfs.promises.copyFile('/nonexistent.txt', '/fail.txt'), + { code: 'ENOENT' } + ); +})().then(common.mustCall()); + +// Test promises.symlink and promises.readlink +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/symlink-target.txt', 'symlink content'); + + await myVfs.promises.symlink('/symlink-target.txt', '/symlink-link.txt'); + + // Verify symlink was created + const lstat = myVfs.lstatSync('/symlink-link.txt'); + assert.strictEqual(lstat.isSymbolicLink(), true); + + // Read through symlink + const content = await myVfs.promises.readFile('/symlink-link.txt', 'utf8'); + assert.strictEqual(content, 'symlink content'); + + // Readlink should return target + const target = await myVfs.promises.readlink('/symlink-link.txt'); + assert.strictEqual(target, '/symlink-target.txt'); + + // Readlink on non-symlink should error + await assert.rejects( + myVfs.promises.readlink('/symlink-target.txt'), + { code: 'EINVAL' } + ); +})().then(common.mustCall()); + +// Test async truncate (via file handle) +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/truncate-test.txt', 'async content'); + + const fd = myVfs.openSync('/truncate-test.txt', 'r+'); + const { getVirtualFd } = require('internal/vfs/fd'); + const handle = getVirtualFd(fd); + + await handle.entry.truncate(5); + myVfs.closeSync(fd); + assert.strictEqual(myVfs.readFileSync('/truncate-test.txt', 'utf8'), 'async'); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-readdir-symlink-recursive.js b/test/parallel/test-vfs-readdir-symlink-recursive.js new file mode 100644 index 00000000000000..841a384cc9f62c --- /dev/null +++ b/test/parallel/test-vfs-readdir-symlink-recursive.js @@ -0,0 +1,54 @@ +// Flags: --experimental-vfs +'use strict'; + +// Recursive readdir must follow symlinks to directories. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.mkdirSync('/real-dir'); +myVfs.writeFileSync('/real-dir/nested.txt', 'nested'); + +myVfs.mkdirSync('/root'); +myVfs.symlinkSync('/real-dir', '/root/symdir'); + +const entries = myVfs.readdirSync('/root', { recursive: true }); +assert.ok(entries.includes('symdir')); +assert.ok( + entries.includes('symdir/nested.txt'), + `Expected 'symdir/nested.txt' in entries: ${entries}`, +); + +// Recursive readdir with withFileTypes:true returns Dirent objects whose +// parentPath reflects the actual location of the entry (not the entry's +// stringified relative path). +{ + const v = vfs.create(); + v.mkdirSync('/r/a/b', { recursive: true }); + v.writeFileSync('/r/top.txt', 'x'); + v.writeFileSync('/r/a/b/leaf.txt', 'y'); + + const dirents = v.readdirSync('/r', { withFileTypes: true, recursive: true }); + const leaf = dirents.find((d) => d.name === 'leaf.txt'); + assert.ok(leaf); + assert.strictEqual(leaf.parentPath, '/r/a/b'); + + const top = dirents.find((d) => d.name === 'top.txt'); + assert.ok(top); + assert.strictEqual(top.parentPath, '/r'); +} + +// Non-recursive readdir with withFileTypes returns mixed entry types +{ + const v = vfs.create(); + v.mkdirSync('/d'); + v.writeFileSync('/d/a.txt', 'x'); + v.mkdirSync('/d/sub'); + v.symlinkSync('a.txt', '/d/lnk'); + const dirents = v.readdirSync('/d', { withFileTypes: true }); + assert.ok(dirents.find((d) => d.name === 'a.txt' && d.isFile())); + assert.ok(dirents.find((d) => d.name === 'sub' && d.isDirectory())); + assert.ok(dirents.find((d) => d.name === 'lnk' && d.isSymbolicLink())); +} diff --git a/test/parallel/test-vfs-readfile-async.js b/test/parallel/test-vfs-readfile-async.js new file mode 100644 index 00000000000000..45ff80ba2d1d5b --- /dev/null +++ b/test/parallel/test-vfs-readfile-async.js @@ -0,0 +1,24 @@ +// Flags: --experimental-vfs +'use strict'; + +// VFS readFile callback API. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/async-read.txt', 'async content'); + +myVfs.readFile('/async-read.txt', 'utf8', common.mustSucceed((data) => { + assert.strictEqual(data, 'async content'); +})); + +myVfs.readFile('/async-read.txt', common.mustSucceed((data) => { + assert.ok(Buffer.isBuffer(data)); + assert.strictEqual(data.toString(), 'async content'); +})); + +myVfs.readFile('/missing.txt', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); +})); diff --git a/test/parallel/test-vfs-readfile-encoding.js b/test/parallel/test-vfs-readfile-encoding.js new file mode 100644 index 00000000000000..4b34a15f05759d --- /dev/null +++ b/test/parallel/test-vfs-readfile-encoding.js @@ -0,0 +1,22 @@ +// Flags: --experimental-vfs +'use strict'; + +// readFileSync with invalid encoding must throw. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'x'); + +assert.throws( + () => myVfs.readFileSync('/file.txt', { encoding: 'bogus' }), + /encoding/i, +); + +// Valid encodings should work +assert.strictEqual(myVfs.readFileSync('/file.txt', 'utf8'), 'x'); +assert.strictEqual(myVfs.readFileSync('/file.txt', { encoding: 'utf8' }), 'x'); +const buf = myVfs.readFileSync('/file.txt'); +assert.ok(Buffer.isBuffer(buf)); diff --git a/test/parallel/test-vfs-readfile-flag.js b/test/parallel/test-vfs-readfile-flag.js new file mode 100644 index 00000000000000..5fcfc902b79188 --- /dev/null +++ b/test/parallel/test-vfs-readfile-flag.js @@ -0,0 +1,39 @@ +// Flags: --experimental-vfs +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Test readFileSync with flag: 'w+' truncates existing file +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'original content'); + + // Reading with 'w+' flag should truncate then read (empty result) + const result = myVfs.readFileSync('/file.txt', { flag: 'w+' }); + assert.strictEqual(result.length, 0); +} + +// Test readFileSync with flag: 'a+' on new file +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir', { recursive: true }); + + // Reading with 'a+' flag should create file if missing and return empty + const result = myVfs.readFileSync('/dir/new.txt', { flag: 'a+' }); + assert.strictEqual(result.length, 0); + + // File should now exist + assert.strictEqual(myVfs.existsSync('/dir/new.txt'), true); +} + +// Test async readFile with flag: 'w+' truncates existing file +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file2.txt', 'some data'); + + myVfs.promises.readFile('/file2.txt', { flag: 'w+' }).then(common.mustCall((result) => { + assert.strictEqual(result.length, 0); + })); +} diff --git a/test/parallel/test-vfs-real-provider-handle.js b/test/parallel/test-vfs-real-provider-handle.js new file mode 100644 index 00000000000000..1da455606695de --- /dev/null +++ b/test/parallel/test-vfs-real-provider-handle.js @@ -0,0 +1,120 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +// RealFileHandle: sync and async file-handle operations, plus EBADF +// behaviour after close. + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); +const { getVirtualFd } = require('internal/vfs/fd'); + +tmpdir.refresh(); +const root = path.join(tmpdir.path, 'real-handle'); +fs.mkdirSync(root, { recursive: true }); +const myVfs = vfs.create(new vfs.RealFSProvider(root)); + +(async () => { + // ===== Sync read/write/stat/truncate via openSync + getVirtualFd ===== + { + fs.writeFileSync(path.join(root, 'sync-rw.txt'), 'hello world'); + const fd = myVfs.openSync('/sync-rw.txt', 'r+'); + const handle = getVirtualFd(fd).entry; + + const buf = Buffer.alloc(5); + assert.strictEqual(handle.readSync(buf, 0, 5, 0), 5); + assert.strictEqual(buf.toString(), 'hello'); + + const wbuf = Buffer.from('zz'); + assert.strictEqual(handle.writeSync(wbuf, 0, 2, 0), 2); + + assert.strictEqual(handle.statSync().isFile(), true); + assert.strictEqual(handle.readFileSync('utf8'), 'zzllo world'); + + handle.writeFileSync('replaced'); + assert.strictEqual(handle.readFileSync('utf8'), 'replaced'); + + myVfs.closeSync(fd); + } + + // ===== Async read/write/stat/truncate via provider.open ===== + { + await myVfs.promises.writeFile('/h2.txt', 'abcdef'); + const handle = await myVfs.provider.open('/h2.txt', 'r+'); + assert.strictEqual(typeof handle.fd, 'number'); + + const buf = Buffer.alloc(3); + assert.strictEqual(handle.readSync(buf, 0, 3, 0), 3); + assert.strictEqual(buf.toString(), 'abc'); + + const r = await handle.read(Buffer.alloc(3), 0, 3, 3); + assert.strictEqual(r.bytesRead, 3); + assert.strictEqual(r.buffer.toString(), 'def'); + + handle.writeSync(Buffer.from('ZZ'), 0, 2, 0); + const w = await handle.write(Buffer.from('YY'), 0, 2, 4); + assert.strictEqual(w.bytesWritten, 2); + + const s1 = handle.statSync(); + const s2 = await handle.stat(); + assert.strictEqual(s1.size, s2.size); + + assert.ok(handle.readFileSync().length > 0); + assert.ok((await handle.readFile()).length > 0); + + handle.writeFileSync('OVERWRITTEN'); + assert.strictEqual(handle.readFileSync('utf8'), 'OVERWRITTEN'); + await handle.writeFile('async-overwrite'); + assert.strictEqual(await handle.readFile('utf8'), 'async-overwrite'); + + handle.truncateSync(3); + await handle.truncate(2); + + await handle.close(); + } + + // ===== EBADF after close ===== + { + await myVfs.promises.writeFile('/h.txt', 'hello'); + const handle = await myVfs.provider.open('/h.txt', 'r'); + await handle.close(); + assert.strictEqual(handle.closed, true); + assert.throws(() => handle.readSync(Buffer.alloc(1), 0, 1, 0), + { code: 'EBADF' }); + await assert.rejects(handle.read(Buffer.alloc(1), 0, 1, 0), + { code: 'EBADF' }); + assert.throws(() => handle.writeSync(Buffer.from('x'), 0, 1, 0), + { code: 'EBADF' }); + await assert.rejects(handle.write(Buffer.from('x'), 0, 1, 0), + { code: 'EBADF' }); + assert.throws(() => handle.readFileSync(), { code: 'EBADF' }); + await assert.rejects(handle.readFile(), { code: 'EBADF' }); + assert.throws(() => handle.writeFileSync('x'), { code: 'EBADF' }); + await assert.rejects(handle.writeFile('x'), { code: 'EBADF' }); + assert.throws(() => handle.statSync(), { code: 'EBADF' }); + await assert.rejects(handle.stat(), { code: 'EBADF' }); + assert.throws(() => handle.truncateSync(), { code: 'EBADF' }); + await assert.rejects(handle.truncate(), { code: 'EBADF' }); + // Subsequent close is a no-op + handle.closeSync(); + await handle.close(); + } + + // ===== Async fd-ops error paths via externally-closed fd ===== + // Run last so the freed fd doesn't get recycled into a sibling test. + { + await myVfs.promises.writeFile('/eb.txt', 'x'); + const handle = await myVfs.provider.open('/eb.txt', 'r+'); + fs.closeSync(handle.fd); + await assert.rejects(handle.read(Buffer.alloc(1), 0, 1, 0), + { code: 'EBADF' }); + await assert.rejects(handle.write(Buffer.from('y'), 0, 1, 0), + { code: 'EBADF' }); + await assert.rejects(handle.stat(), { code: 'EBADF' }); + await assert.rejects(handle.truncate(0), { code: 'EBADF' }); + await assert.rejects(handle.close(), { code: 'EBADF' }); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-real-provider-promises.js b/test/parallel/test-vfs-real-provider-promises.js new file mode 100644 index 00000000000000..932a30fd086dab --- /dev/null +++ b/test/parallel/test-vfs-real-provider-promises.js @@ -0,0 +1,55 @@ +// Flags: --experimental-vfs +'use strict'; + +// Promises API for RealFSProvider: every async/promises method, +// plus access() existing/missing. + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +tmpdir.refresh(); +const root = path.join(tmpdir.path, 'real-provider-promises'); +fs.mkdirSync(root, { recursive: true }); +const myVfs = vfs.create(new vfs.RealFSProvider(root)); + +(async () => { + // writeFile + readFile + await myVfs.promises.writeFile('/a.txt', 'hello'); + assert.strictEqual(await myVfs.promises.readFile('/a.txt', 'utf8'), 'hello'); + + // stat / lstat / access + const st = await myVfs.promises.stat('/a.txt'); + assert.strictEqual(st.size, 5); + assert.strictEqual((await myVfs.promises.lstat('/a.txt')).isFile(), true); + await myVfs.promises.access('/a.txt'); + await assert.rejects(myVfs.promises.access('/missing.txt'), + { code: 'ENOENT' }); + + // mkdir / readdir / rmdir + await myVfs.promises.mkdir('/d/sub', { recursive: true }); + const entries = await myVfs.promises.readdir('/d'); + assert.deepStrictEqual(entries.sort(), ['sub']); + await myVfs.promises.rmdir('/d/sub'); + + // rename + await myVfs.promises.writeFile('/old.txt', 'x'); + await myVfs.promises.rename('/old.txt', '/new.txt'); + assert.strictEqual(myVfs.existsSync('/old.txt'), false); + assert.strictEqual(myVfs.existsSync('/new.txt'), true); + + // unlink + await myVfs.promises.unlink('/new.txt'); + assert.strictEqual(myVfs.existsSync('/new.txt'), false); + + // copyFile + await myVfs.promises.copyFile('/a.txt', '/copy.txt'); + assert.strictEqual(await myVfs.promises.readFile('/copy.txt', 'utf8'), 'hello'); + + // open async error + await assert.rejects(myVfs.provider.open('/missing.txt', 'r'), + { code: 'ENOENT' }); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-real-provider-symlinks.js b/test/parallel/test-vfs-real-provider-symlinks.js new file mode 100644 index 00000000000000..d84c17ecd8490d --- /dev/null +++ b/test/parallel/test-vfs-real-provider-symlinks.js @@ -0,0 +1,111 @@ +// Flags: --experimental-vfs +'use strict'; + +// Symlink and path-escape behaviour for RealFSProvider. + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +tmpdir.refresh(); +const root = path.join(tmpdir.path, 'real-symlinks'); +fs.mkdirSync(root, { recursive: true }); +const myVfs = vfs.create(new vfs.RealFSProvider(root)); + +(async () => { + // .. traversal in VFS paths can't escape root + { + const subDir = path.join(root, 'sandbox'); + fs.mkdirSync(subDir, { recursive: true }); + const subVfs = vfs.create(new vfs.RealFSProvider(subDir)); + assert.throws(() => subVfs.statSync('/../hello.txt'), { code: 'ENOENT' }); + assert.throws(() => subVfs.readFileSync('/../../../etc/passwd'), + { code: 'ENOENT' }); + fs.rmdirSync(subDir); + } + + // Path traversal via a non-leading-slash relative path + { + const escapeProvider = new vfs.RealFSProvider(root); + assert.throws(() => escapeProvider.statSync('../etc/passwd'), + { code: 'ENOENT' }); + } + + // Symlinks: absolute target rejected with EACCES + { + assert.throws(() => myVfs.symlinkSync('/etc/passwd', '/escape'), + { code: 'EACCES' }); + await assert.rejects(myVfs.promises.symlink('/etc/passwd', '/escape2'), + { code: 'EACCES' }); + } + + // Symlinks: relative target outside root rejected with EACCES + { + assert.throws(() => myVfs.symlinkSync('../../escape', '/bad-link'), + { code: 'EACCES' }); + await assert.rejects( + myVfs.promises.symlink('../../escape', '/bad-link2'), + { code: 'EACCES' }); + } + + // Symlink with relative target inside root — readlink returns target as-is + { + fs.writeFileSync(path.join(root, 'rel-target.txt'), 'ok'); + fs.symlinkSync('rel-target.txt', path.join(root, 'rel-link')); + assert.strictEqual(myVfs.readlinkSync('/rel-link'), 'rel-target.txt'); + assert.strictEqual(await myVfs.promises.readlink('/rel-link'), + 'rel-target.txt'); + } + + // Symlink whose absolute target is inside root → translated to VFS path + { + fs.writeFileSync(path.join(root, 'target.txt'), 'x'); + fs.symlinkSync(path.join(root, 'target.txt'), + path.join(root, 'abs-link')); + assert.strictEqual(myVfs.readlinkSync('/abs-link'), '/target.txt'); + assert.strictEqual(await myVfs.promises.readlink('/abs-link'), + '/target.txt'); + } + + // Symlink whose absolute target equals root → '/' + { + fs.symlinkSync(root, path.join(root, 'root-link')); + assert.strictEqual(myVfs.readlinkSync('/root-link'), '/'); + assert.strictEqual(await myVfs.promises.readlink('/root-link'), '/'); + } + + // Symlink that points outside root: realpath rejects with EACCES + { + fs.writeFileSync(path.join(tmpdir.path, 'outside.txt'), 'forbidden'); + fs.symlinkSync(path.join(tmpdir.path, 'outside.txt'), + path.join(root, 'esc-link')); + + // Readlink returns the absolute target as-is (no translation since it's + // outside the root) + const target = myVfs.readlinkSync('/esc-link'); + assert.strictEqual(target, path.join(tmpdir.path, 'outside.txt')); + + // Realpath rejects: the resolved target escapes root + assert.throws(() => myVfs.realpathSync('/esc-link'), { code: 'EACCES' }); + await assert.rejects(myVfs.promises.realpath('/esc-link'), + { code: 'EACCES' }); + } + + // Realpath on root and on a subdir + { + fs.mkdirSync(path.join(root, 'sub2'), { recursive: true }); + assert.strictEqual(myVfs.realpathSync('/'), '/'); + assert.strictEqual(myVfs.realpathSync('/sub2'), '/sub2'); + assert.strictEqual(await myVfs.promises.realpath('/sub2'), '/sub2'); + } + + // RealFSProvider with a rootPath that already ends in path.sep + { + fs.writeFileSync(path.join(root, 'tr.txt'), 'tr'); + const trailingProvider = new vfs.RealFSProvider(root + path.sep); + assert.strictEqual(trailingProvider.readFileSync('/tr.txt', 'utf8'), 'tr'); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-real-provider-watch.js b/test/parallel/test-vfs-real-provider-watch.js new file mode 100644 index 00000000000000..f4218fa408ee4d --- /dev/null +++ b/test/parallel/test-vfs-real-provider-watch.js @@ -0,0 +1,40 @@ +// Flags: --experimental-vfs +'use strict'; + +// watch / promises.watch / watchFile through RealFSProvider. + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +tmpdir.refresh(); +const root = path.join(tmpdir.path, 'real-provider-watch'); +fs.mkdirSync(root, { recursive: true }); +const myVfs = vfs.create(new vfs.RealFSProvider(root)); + +assert.strictEqual(myVfs.provider.supportsWatch, true); + +// fs.watch wrapper +{ + fs.writeFileSync(path.join(root, 'watch-me.txt'), 'a'); + const watcher = myVfs.watch('/watch-me.txt', { persistent: false }); + watcher.close(); +} + +// promises.watch wrapper +(async () => { + fs.writeFileSync(path.join(root, 'pwatch.txt'), 'a'); + const iter = myVfs.promises.watch('/pwatch.txt', { persistent: false }); + await iter.return(); +})().then(common.mustCall()); + +// watchFile / unwatchFile +{ + fs.writeFileSync(path.join(root, 'wf.txt'), 'a'); + const listener = () => {}; + myVfs.watchFile('/wf.txt', { persistent: false }, listener); + myVfs.unwatchFile('/wf.txt', listener); +} diff --git a/test/parallel/test-vfs-real-provider.js b/test/parallel/test-vfs-real-provider.js new file mode 100644 index 00000000000000..b1dc97fc7f00f7 --- /dev/null +++ b/test/parallel/test-vfs-real-provider.js @@ -0,0 +1,148 @@ +// Flags: --experimental-vfs +'use strict'; + +// Synchronous API for RealFSProvider: construction, basic file ops, +// stats, directories, copy, realpath. Async/promises live in +// test-vfs-real-provider-promises.js, file-handle ops live in +// test-vfs-real-provider-handle.js, and symlinks/path-escape live in +// test-vfs-real-provider-symlinks.js. + +require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vfs = require('node:vfs'); + +tmpdir.refresh(); + +const testDir = path.join(tmpdir.path, 'vfs-real-provider'); +fs.mkdirSync(testDir, { recursive: true }); + +// Capability flags + construction +{ + const provider = new vfs.RealFSProvider(testDir); + assert.ok(provider); + assert.strictEqual(provider.rootPath, testDir); + assert.strictEqual(provider.readonly, false); + assert.strictEqual(provider.supportsSymlinks, true); + assert.strictEqual(provider.supportsWatch, true); +} + +// Invalid rootPath +{ + assert.throws(() => new vfs.RealFSProvider(''), + { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws(() => new vfs.RealFSProvider(123), + { code: 'ERR_INVALID_ARG_VALUE' }); +} + +// vfs.create(provider) wires it up +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + assert.ok(realVfs); + assert.strictEqual(realVfs.readonly, false); +} + +// readFile / writeFile sync +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + realVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); + const realPath = path.join(testDir, 'hello.txt'); + assert.strictEqual(fs.existsSync(realPath), true); + assert.strictEqual(fs.readFileSync(realPath, 'utf8'), 'Hello from VFS!'); + assert.strictEqual(realVfs.readFileSync('/hello.txt', 'utf8'), 'Hello from VFS!'); + fs.unlinkSync(realPath); +} + +// statSync / lstatSync +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + fs.writeFileSync(path.join(testDir, 'stat-test.txt'), 'content'); + fs.mkdirSync(path.join(testDir, 'stat-dir'), { recursive: true }); + + assert.strictEqual(realVfs.statSync('/stat-test.txt').isFile(), true); + assert.strictEqual(realVfs.statSync('/stat-dir').isDirectory(), true); + assert.strictEqual(realVfs.lstatSync('/stat-test.txt').isFile(), true); + + assert.throws(() => realVfs.statSync('/nonexistent'), + { code: 'ENOENT' }); + + fs.unlinkSync(path.join(testDir, 'stat-test.txt')); + fs.rmdirSync(path.join(testDir, 'stat-dir')); +} + +// readdirSync +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + fs.mkdirSync(path.join(testDir, 'readdir-test', 'subdir'), { recursive: true }); + fs.writeFileSync(path.join(testDir, 'readdir-test', 'a.txt'), 'a'); + fs.writeFileSync(path.join(testDir, 'readdir-test', 'b.txt'), 'b'); + + const entries = realVfs.readdirSync('/readdir-test'); + assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt', 'subdir']); + + const dirents = realVfs.readdirSync('/readdir-test', { withFileTypes: true }); + assert.strictEqual(dirents.length, 3); + assert.ok(dirents.find((d) => d.name === 'a.txt' && d.isFile())); + assert.ok(dirents.find((d) => d.name === 'subdir' && d.isDirectory())); + + fs.unlinkSync(path.join(testDir, 'readdir-test', 'a.txt')); + fs.unlinkSync(path.join(testDir, 'readdir-test', 'b.txt')); + fs.rmdirSync(path.join(testDir, 'readdir-test', 'subdir')); + fs.rmdirSync(path.join(testDir, 'readdir-test')); +} + +// mkdir / rmdir / recursive mkdir +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + realVfs.mkdirSync('/new-dir'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'new-dir')), true); + realVfs.rmdirSync('/new-dir'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'new-dir')), false); + + realVfs.mkdirSync('/deep/nested/dir', { recursive: true }); + assert.strictEqual(fs.existsSync(path.join(testDir, 'deep/nested/dir')), true); + fs.rmdirSync(path.join(testDir, 'deep/nested/dir')); + fs.rmdirSync(path.join(testDir, 'deep/nested')); + fs.rmdirSync(path.join(testDir, 'deep')); +} + +// unlink +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + fs.writeFileSync(path.join(testDir, 'to-delete.txt'), 'delete me'); + assert.strictEqual(realVfs.existsSync('/to-delete.txt'), true); + realVfs.unlinkSync('/to-delete.txt'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'to-delete.txt')), false); +} + +// rename +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + fs.writeFileSync(path.join(testDir, 'old-name.txt'), 'rename me'); + realVfs.renameSync('/old-name.txt', '/new-name.txt'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'old-name.txt')), false); + assert.strictEqual(fs.readFileSync(path.join(testDir, 'new-name.txt'), 'utf8'), + 'rename me'); + fs.unlinkSync(path.join(testDir, 'new-name.txt')); +} + +// copyFile +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + fs.writeFileSync(path.join(testDir, 'source.txt'), 'copy me'); + realVfs.copyFileSync('/source.txt', '/dest.txt'); + assert.strictEqual(fs.readFileSync(path.join(testDir, 'dest.txt'), 'utf8'), + 'copy me'); + fs.unlinkSync(path.join(testDir, 'source.txt')); + fs.unlinkSync(path.join(testDir, 'dest.txt')); +} + +// realpathSync (non-symlink) +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + fs.writeFileSync(path.join(testDir, 'real.txt'), 'content'); + assert.strictEqual(realVfs.realpathSync('/real.txt'), '/real.txt'); + fs.unlinkSync(path.join(testDir, 'real.txt')); +} diff --git a/test/parallel/test-vfs-rename.js b/test/parallel/test-vfs-rename.js new file mode 100644 index 00000000000000..26506f01dd7338 --- /dev/null +++ b/test/parallel/test-vfs-rename.js @@ -0,0 +1,46 @@ +// Flags: --experimental-vfs +'use strict'; + +// Rename behaviour: overwrite, type mismatches, same-parent rename. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Renaming a file onto a directory throws EISDIR +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'x'); + myVfs.mkdirSync('/dir'); + assert.throws(() => myVfs.renameSync('/file.txt', '/dir'), + { code: 'EISDIR' }); +} + +// Renaming a directory onto a file throws ENOTDIR +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'x'); + myVfs.mkdirSync('/dir'); + assert.throws(() => myVfs.renameSync('/dir', '/file.txt'), + { code: 'ENOTDIR' }); +} + +// Renaming a file onto another file overwrites +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/a.txt', 'a'); + myVfs.writeFileSync('/b.txt', 'b'); + myVfs.renameSync('/a.txt', '/b.txt'); + assert.strictEqual(myVfs.readFileSync('/b.txt', 'utf8'), 'a'); + assert.strictEqual(myVfs.existsSync('/a.txt'), false); +} + +// Renaming within the same parent directory +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d'); + myVfs.writeFileSync('/d/a.txt', 'x'); + myVfs.renameSync('/d/a.txt', '/d/b.txt'); + assert.strictEqual(myVfs.existsSync('/d/a.txt'), false); + assert.strictEqual(myVfs.existsSync('/d/b.txt'), true); +} diff --git a/test/parallel/test-vfs-rm-edge-cases.js b/test/parallel/test-vfs-rm-edge-cases.js new file mode 100644 index 00000000000000..9961940aea830e --- /dev/null +++ b/test/parallel/test-vfs-rm-edge-cases.js @@ -0,0 +1,70 @@ +// Flags: --experimental-vfs +'use strict'; + +// Tests for VFS rmSync edge cases: +// - rmSync on a directory without recursive must throw EISDIR +// - rmSync on a symlink must not recurse into the target directory + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// rmSync on a directory without { recursive: true } must throw EISDIR. +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir'); + + assert.throws(() => myVfs.rmSync('/dir'), { code: 'EISDIR' }); + // Directory should still exist after the failed rmSync + assert.strictEqual(myVfs.existsSync('/dir'), true); +} + +// rmSync(link, { recursive: true }) removes symlink without recursing +// into the target directory. +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir/sub', { recursive: true }); + myVfs.writeFileSync('/dir/sub/file.txt', 'x'); + myVfs.symlinkSync('/dir', '/link'); + + myVfs.rmSync('/link', { recursive: true }); + + // Symlink should be removed + assert.strictEqual(myVfs.existsSync('/link'), false); + // Target directory and its contents should still exist + assert.strictEqual(myVfs.existsSync('/dir/sub/file.txt'), true); +} + +// rmSync with force: true ignores ENOENT +{ + const myVfs = vfs.create(); + myVfs.rmSync('/missing.txt', { force: true }); +} + +// rmSync without force on missing path throws ENOENT +{ + const myVfs = vfs.create(); + assert.throws(() => myVfs.rmSync('/missing.txt'), { code: 'ENOENT' }); +} + +// promises.rm equivalents +(async () => { + const myVfs = vfs.create(); + myVfs.mkdirSync('/d/sub', { recursive: true }); + myVfs.writeFileSync('/d/sub/f.txt', 'x'); + + await assert.rejects(myVfs.promises.rm('/d'), { code: 'EISDIR' }); + await myVfs.promises.rm('/d', { recursive: true }); + assert.strictEqual(myVfs.existsSync('/d'), false); + + await myVfs.promises.rm('/missing', { force: true }); + await assert.rejects(myVfs.promises.rm('/missing'), { code: 'ENOENT' }); + + // promises.rm on symlink unlinks without recursion + myVfs.mkdirSync('/d2/sub', { recursive: true }); + myVfs.writeFileSync('/d2/sub/file.txt', 'x'); + myVfs.symlinkSync('/d2', '/link2'); + await myVfs.promises.rm('/link2', { recursive: true }); + assert.strictEqual(myVfs.existsSync('/link2'), false); + assert.strictEqual(myVfs.existsSync('/d2/sub/file.txt'), true); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-rmdir-symlink.js b/test/parallel/test-vfs-rmdir-symlink.js new file mode 100644 index 00000000000000..fba1981322f559 --- /dev/null +++ b/test/parallel/test-vfs-rmdir-symlink.js @@ -0,0 +1,31 @@ +// Flags: --experimental-vfs +'use strict'; + +// rmdirSync on a symlink to a directory should throw ENOTDIR + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir'); + myVfs.symlinkSync('/dir', '/link'); + + assert.throws(() => myVfs.rmdirSync('/link'), + { code: 'ENOTDIR' }); + + // Both the symlink and directory should still exist + assert.ok(myVfs.existsSync('/link')); + assert.ok(myVfs.existsSync('/dir')); +} + +// promises.rmdir equivalent +(async () => { + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir'); + myVfs.symlinkSync('/dir', '/link'); + + await assert.rejects(myVfs.promises.rmdir('/link'), + { code: 'ENOTDIR' }); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-stats-bigint.js b/test/parallel/test-vfs-stats-bigint.js new file mode 100644 index 00000000000000..074ac9fa379f88 --- /dev/null +++ b/test/parallel/test-vfs-stats-bigint.js @@ -0,0 +1,36 @@ +// Flags: --experimental-vfs +'use strict'; + +// Verify { bigint: true } returns BigInt values for VFS stats. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'x'); + +const st = myVfs.statSync('/file.txt', { bigint: true }); +assert.strictEqual(typeof st.size, 'bigint'); +assert.strictEqual(st.size, 1n); +assert.strictEqual(typeof st.ino, 'bigint'); +assert.strictEqual(typeof st.mode, 'bigint'); + +// Bigint stats for directories +{ + const v = vfs.create(); + v.mkdirSync('/dir'); + const st = v.statSync('/dir', { bigint: true }); + assert.strictEqual(typeof st.size, 'bigint'); + assert.strictEqual(st.isDirectory(), true); +} + +// Bigint stats for symlinks via lstat +{ + const v = vfs.create(); + v.mkdirSync('/dir'); + v.symlinkSync('/dir', '/link'); + const st = v.lstatSync('/link', { bigint: true }); + assert.strictEqual(typeof st.size, 'bigint'); + assert.strictEqual(st.isSymbolicLink(), true); +} diff --git a/test/parallel/test-vfs-stats-helpers.js b/test/parallel/test-vfs-stats-helpers.js new file mode 100644 index 00000000000000..3df0701138ce9c --- /dev/null +++ b/test/parallel/test-vfs-stats-helpers.js @@ -0,0 +1,80 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +// Exercise the default-option paths in createFileStats / createDirectoryStats +// / createSymlinkStats / createZeroStats. These defaults aren't taken when +// MemoryProvider populates every option from the entry, so we drive the +// helpers directly. + +require('../common'); +const assert = require('assert'); +const { + createFileStats, + createDirectoryStats, + createSymlinkStats, + createZeroStats, +} = require('internal/vfs/stats'); + +// All defaults — no options object at all +{ + const st = createFileStats(42); + assert.strictEqual(st.size, 42); + assert.strictEqual((st.mode & 0o777), 0o644); + assert.strictEqual(st.nlink, 1); + assert.ok(st.isFile()); + + const dirSt = createDirectoryStats(); + assert.ok(dirSt.isDirectory()); + assert.strictEqual((dirSt.mode & 0o777), 0o755); + + const linkSt = createSymlinkStats(7); + assert.ok(linkSt.isSymbolicLink()); + assert.strictEqual((linkSt.mode & 0o777), 0o777); + assert.strictEqual(linkSt.size, 7); +} + +// Empty options object exercises the `?? default` right-hand side. +{ + const st = createFileStats(1, {}); + assert.ok(st.isFile()); + const dirSt = createDirectoryStats({}); + assert.ok(dirSt.isDirectory()); + const linkSt = createSymlinkStats(0, {}); + assert.ok(linkSt.isSymbolicLink()); +} + +// Bigint variant of zero-stats +{ + const z = createZeroStats({ bigint: true }); + assert.strictEqual(typeof z.size, 'bigint'); + assert.strictEqual(z.size, 0n); + assert.strictEqual(z.mode, 0n); +} + +// Non-bigint zero-stats with no options +{ + const z = createZeroStats(); + assert.strictEqual(z.size, 0); + assert.strictEqual(z.mode, 0); +} + +// Cover the `process.getuid?.() ?? 0` fallback (Windows-like environment). +// We stub the optional methods to simulate their absence. +{ + const realUid = process.getuid; + const realGid = process.getgid; + process.getuid = undefined; + process.getgid = undefined; + try { + const fs = createFileStats(0); + assert.strictEqual(fs.uid, 0); + assert.strictEqual(fs.gid, 0); + const ds = createDirectoryStats(); + assert.strictEqual(ds.uid, 0); + const ls = createSymlinkStats(0); + assert.strictEqual(ls.uid, 0); + } finally { + process.getuid = realUid; + process.getgid = realGid; + } +} diff --git a/test/parallel/test-vfs-stats-ino-dev.js b/test/parallel/test-vfs-stats-ino-dev.js new file mode 100644 index 00000000000000..9a5e69d603bddf --- /dev/null +++ b/test/parallel/test-vfs-stats-ino-dev.js @@ -0,0 +1,24 @@ +// Flags: --experimental-vfs +'use strict'; + +// VFS stats must have non-zero, unique ino and a distinctive dev. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/f1.txt', 'a'); +myVfs.writeFileSync('/f2.txt', 'b'); + +const s1 = myVfs.statSync('/f1.txt'); +const s2 = myVfs.statSync('/f2.txt'); + +// Dev should be distinctive VFS value (4085 = 0xVF5) +assert.strictEqual(s1.dev, 4085); +assert.strictEqual(s2.dev, 4085); + +// Ino should be unique per call +assert.notStrictEqual(s1.ino, 0); +assert.notStrictEqual(s2.ino, 0); +assert.notStrictEqual(s1.ino, s2.ino); diff --git a/test/parallel/test-vfs-stream-errors.js b/test/parallel/test-vfs-stream-errors.js new file mode 100644 index 00000000000000..17c5308c8cd2c0 --- /dev/null +++ b/test/parallel/test-vfs-stream-errors.js @@ -0,0 +1,70 @@ +// Flags: --experimental-vfs +'use strict'; + +// Error paths in VFS streams: missing files, EBADF on closed fds, +// destroyed streams, and write to a path under a missing parent. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Read of a nonexistent file emits 'error' +{ + const stream = myVfs.createReadStream('/missing.txt'); + stream.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); +} + +// Read on a stream whose fd has been pre-closed → EBADF on first _read +{ + myVfs.writeFileSync('/x.txt', 'hi'); + const fd = myVfs.openSync('/x.txt'); + const rs = myVfs.createReadStream('/x.txt', { fd, autoClose: false }); + myVfs.closeSync(fd); + rs.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'EBADF'); + })); + rs.resume(); +} + +// Read stream with autoClose:true after the fd is invalidated — covers the +// close-error swallow path inside the stream's #close. +{ + myVfs.writeFileSync('/cl.txt', 'data'); + const fd = myVfs.openSync('/cl.txt'); + const rs = myVfs.createReadStream('/cl.txt', { fd, autoClose: true }); + myVfs.closeSync(fd); + rs.on('error', common.mustCall()); + rs.resume(); +} + +// WriteStream synchronously failing to open → destroys on next tick +{ + const ws = myVfs.createWriteStream('/missing-dir/foo.txt', { flags: 'wx' }); + ws.on('error', common.mustCall((err) => { + assert.ok(err); + })); +} + +// WriteStream destroyed before write() — covers the destroyed-true branch +// in _write. +{ + const ws = myVfs.createWriteStream('/wd.txt'); + ws.on('error', () => {}); + ws.destroy(new Error('boom')); +} + +// _write errors when writeSync throws (closed fd) +{ + myVfs.writeFileSync('/wfd.txt', ''); + const fd = myVfs.openSync('/wfd.txt', 'w'); + const ws = myVfs.createWriteStream('/wfd.txt', { fd, autoClose: false }); + myVfs.closeSync(fd); + ws.on('error', common.mustCall((err) => { + assert.ok(err); + })); + ws.write('x'); +} diff --git a/test/parallel/test-vfs-stream-explicit-fd.js b/test/parallel/test-vfs-stream-explicit-fd.js new file mode 100644 index 00000000000000..d439e441a8699d --- /dev/null +++ b/test/parallel/test-vfs-stream-explicit-fd.js @@ -0,0 +1,57 @@ +// Flags: --experimental-vfs +'use strict'; + +// Test createReadStream / createWriteStream with an explicit `fd` option. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'hello world'); + +function readStream(stream) { + return new Promise((resolve, reject) => { + const chunks = []; + stream.on('data', (c) => chunks.push(c)); + stream.on('end', () => resolve(Buffer.concat(chunks).toString())); + stream.on('error', reject); + }); +} + +// Read using an existing fd; autoClose:false leaves fd open +{ + const fd = myVfs.openSync('/file.txt', 'r'); + const stream = myVfs.createReadStream('/file.txt', { fd, autoClose: false }); + let opened = false; + stream.on('open', () => { opened = true; }); + readStream(stream).then(common.mustCall((s) => { + assert.strictEqual(s, 'hello world'); + assert.strictEqual(opened, true); + myVfs.closeSync(fd); + })); +} + +// WriteStream with explicit fd; autoClose:false leaves the fd open +(async () => { + const fd = myVfs.openSync('/fd-write.txt', 'w'); + const stream = myVfs.createWriteStream('/fd-write.txt', { fd, autoClose: false }); + await new Promise((resolve) => stream.on('ready', resolve)); + await new Promise((resolve, reject) => + stream.end('via-fd', (err) => (err ? reject(err) : resolve()))); + myVfs.closeSync(fd); + assert.strictEqual(myVfs.readFileSync('/fd-write.txt', 'utf8'), 'via-fd'); +})().then(common.mustCall()); + +// WriteStream with explicit fd + start position +(async () => { + myVfs.writeFileSync('/pad.txt', 'AAAAAAAAAA'); + const fd = myVfs.openSync('/pad.txt', 'r+'); + const ws = myVfs.createWriteStream('/pad.txt', { fd, start: 5, autoClose: false }); + await new Promise((resolve) => ws.on('ready', resolve)); + await new Promise((resolve, reject) => + ws.end('XX', (err) => (err ? reject(err) : resolve()))); + myVfs.closeSync(fd); + // Position 5 → "AAAAAXXAAA" + assert.strictEqual(myVfs.readFileSync('/pad.txt', 'utf8'), 'AAAAAXXAAA'); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-stream-properties.js b/test/parallel/test-vfs-stream-properties.js new file mode 100644 index 00000000000000..98691c4961ed21 --- /dev/null +++ b/test/parallel/test-vfs-stream-properties.js @@ -0,0 +1,40 @@ +// Flags: --experimental-vfs +'use strict'; + +// VFS streams must expose bytesRead, bytesWritten, and pending. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// ReadStream: bytesRead and pending +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/stream.txt', 'stream data'); + + const rs = myVfs.createReadStream('/stream.txt'); + assert.strictEqual(rs.pending, true); + + rs.on('data', common.mustCall(() => { + assert.strictEqual(rs.pending, false); + assert.ok(rs.bytesRead > 0); + })); + + rs.on('end', common.mustCall()); +} + +// WriteStream: bytesWritten and pending +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/out.txt', ''); + + const ws = myVfs.createWriteStream('/out.txt'); + assert.strictEqual(ws.pending, true); + assert.strictEqual(ws.bytesWritten, 0); + + ws.write('hello', common.mustCall(() => { + assert.strictEqual(ws.pending, false); + assert.strictEqual(ws.bytesWritten, 5); + ws.end(); + })); +} diff --git a/test/parallel/test-vfs-stream-validation.js b/test/parallel/test-vfs-stream-validation.js new file mode 100644 index 00000000000000..7e599fccf9acd0 --- /dev/null +++ b/test/parallel/test-vfs-stream-validation.js @@ -0,0 +1,29 @@ +// Flags: --experimental-vfs +'use strict'; + +// VFS stream constructors must validate start/end synchronously. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'abc'); + +// ReadStream: start > end must throw ERR_OUT_OF_RANGE synchronously +assert.throws( + () => myVfs.createReadStream('/file.txt', { start: 2, end: 1 }), + { code: 'ERR_OUT_OF_RANGE' }, +); + +// ReadStream: negative start +assert.throws( + () => myVfs.createReadStream('/file.txt', { start: -1 }), + { code: 'ERR_OUT_OF_RANGE' }, +); + +// ReadStream: negative end +assert.throws( + () => myVfs.createReadStream('/file.txt', { end: -1 }), + { code: 'ERR_OUT_OF_RANGE' }, +); diff --git a/test/parallel/test-vfs-streams.js b/test/parallel/test-vfs-streams.js new file mode 100644 index 00000000000000..d1f50a609d8018 --- /dev/null +++ b/test/parallel/test-vfs-streams.js @@ -0,0 +1,302 @@ +// Flags: --experimental-vfs +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Test basic createReadStream +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'hello world'); + + const stream = myVfs.createReadStream('/file.txt'); + let data = ''; + + stream.on('open', common.mustCall((fd) => { + assert.ok((fd & 0x40000000) !== 0); + })); + + stream.on('ready', common.mustCall()); + + stream.on('data', (chunk) => { + data += chunk; + }); + + stream.on('end', common.mustCall(() => { + assert.strictEqual(data, 'hello world'); + })); + + stream.on('close', common.mustCall()); +} + +// Test createReadStream with encoding +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/encoded.txt', 'encoded content'); + + const stream = myVfs.createReadStream('/encoded.txt', { encoding: 'utf8' }); + let data = ''; + let receivedString = false; + + stream.on('data', (chunk) => { + if (typeof chunk === 'string') { + receivedString = true; + } + data += chunk; + }); + + stream.on('end', common.mustCall(() => { + assert.strictEqual(receivedString, true); + assert.strictEqual(data, 'encoded content'); + })); +} + +// Test createReadStream with start and end +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/range.txt', '0123456789'); + + const stream = myVfs.createReadStream('/range.txt', { + start: 2, + end: 5, + }); + let data = ''; + + stream.on('data', (chunk) => { + data += chunk; + }); + + stream.on('end', common.mustCall(() => { + // End is inclusive, so positions 2, 3, 4, 5 = "2345" (4 chars) + assert.strictEqual(data, '2345'); + })); +} + +// Test createReadStream with start only +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/start.txt', 'abcdefghij'); + + const stream = myVfs.createReadStream('/start.txt', { start: 5 }); + let data = ''; + + stream.on('data', (chunk) => { + data += chunk; + }); + + stream.on('end', common.mustCall(() => { + assert.strictEqual(data, 'fghij'); + })); +} + +// Test createReadStream with non-existent file +{ + const myVfs = vfs.create(); + + const stream = myVfs.createReadStream('/nonexistent.txt'); + + stream.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); +} + +// Test createReadStream path property +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/path-test.txt', 'test'); + + const stream = myVfs.createReadStream('/path-test.txt'); + assert.strictEqual(stream.path, '/path-test.txt'); + + stream.on('data', () => {}); // Consume data + stream.on('end', common.mustCall()); +} + +// Test createReadStream with small highWaterMark +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/small-hwm.txt', 'AAAABBBBCCCCDDDD'); + + const stream = myVfs.createReadStream('/small-hwm.txt', { + highWaterMark: 4, + }); + + const chunks = []; + stream.on('data', (chunk) => { + chunks.push(chunk.toString()); + }); + + stream.on('end', common.mustCall(() => { + // With highWaterMark of 4, we should get multiple chunks + assert.ok(chunks.length >= 1); + assert.strictEqual(chunks.join(''), 'AAAABBBBCCCCDDDD'); + })); +} + +// Test createReadStream destroy +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/destroy.txt', 'content to destroy'); + + const stream = myVfs.createReadStream('/destroy.txt'); + + stream.on('open', common.mustCall(() => { + stream.destroy(); + })); + + stream.on('close', common.mustCall()); +} + +// Test createReadStream with large file +{ + const myVfs = vfs.create(); + const largeContent = 'X'.repeat(100000); + myVfs.writeFileSync('/large.txt', largeContent); + + const stream = myVfs.createReadStream('/large.txt'); + let receivedLength = 0; + + stream.on('data', (chunk) => { + receivedLength += chunk.length; + }); + + stream.on('end', common.mustCall(() => { + assert.strictEqual(receivedLength, 100000); + })); +} + +// Test createReadStream pipe to another stream +{ + const myVfs = vfs.create(); + const { Writable } = require('stream'); + + myVfs.writeFileSync('/pipe-source.txt', 'pipe this content'); + + const stream = myVfs.createReadStream('/pipe-source.txt'); + let collected = ''; + + const writable = new Writable({ + write(chunk, encoding, callback) { + collected += chunk; + callback(); + }, + }); + + stream.pipe(writable); + + writable.on('finish', common.mustCall(() => { + assert.strictEqual(collected, 'pipe this content'); + })); +} + +// Test autoClose: false +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/no-auto-close.txt', 'content'); + + const stream = myVfs.createReadStream('/no-auto-close.txt', { + autoClose: false, + }); + + stream.on('close', common.mustCall()); + + stream.on('data', () => {}); + + stream.on('end', common.mustCall(() => { + // With autoClose: false, close should not be emitted automatically + // We need to manually destroy the stream + setImmediate(() => { + stream.destroy(); + }); + })); +} + +// ==================== Additional coverage ==================== + +const { Readable } = require('stream'); +const { pipeline } = require('stream/promises'); + +// Slicing read stream with start/end +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/slice.txt', 'hello world'); + const rs = myVfs.createReadStream('/slice.txt', { start: 6, end: 10 }); + const chunks = []; + rs.on('data', (c) => chunks.push(c)); + rs.on('end', common.mustCall(() => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'world'); + })); +} + +// start: beyond file size → empty stream +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/sm.txt', 'abc'); + const rs = myVfs.createReadStream('/sm.txt', { start: 10 }); + rs.on('data', () => assert.fail('no data expected')); + rs.on('end', common.mustCall()); +} + +// Empty file → end immediately +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/empty.txt', ''); + const rs = myVfs.createReadStream('/empty.txt'); + rs.on('data', () => assert.fail('no data expected')); + rs.on('end', common.mustCall()); +} + +// Pipeline write +(async () => { + const myVfs = vfs.create(); + await pipeline( + Readable.from([Buffer.from('hello'), Buffer.from(' world')]), + myVfs.createWriteStream('/out.txt'), + ); + assert.strictEqual(myVfs.readFileSync('/out.txt', 'utf8'), 'hello world'); +})().then(common.mustCall()); + +// Pipeline write with start position +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/pad.txt', 'AAAAAAAAAA'); + await pipeline( + Readable.from([Buffer.from('XX')]), + myVfs.createWriteStream('/pad.txt', { start: 3, flags: 'r+' }), + ); + assert.strictEqual(myVfs.readFileSync('/pad.txt', 'utf8'), 'AAAXXAAAAA'); +})().then(common.mustCall()); + +// Write string chunk + encoding callback +(async () => { + const myVfs = vfs.create(); + const stream = myVfs.createWriteStream('/str.txt'); + await new Promise((resolve, reject) => { + stream.write('hello', 'utf8', (err) => (err ? reject(err) : resolve())); + }); + await new Promise((resolve) => stream.end(resolve)); + assert.strictEqual(myVfs.readFileSync('/str.txt', 'utf8'), 'hello'); +})().then(common.mustCall()); + +// path getter +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/p.txt', 'x'); + const rs = myVfs.createReadStream('/p.txt'); + assert.strictEqual(rs.path, '/p.txt'); + rs.destroy(); + + const ws = myVfs.createWriteStream('/p2.txt'); + assert.strictEqual(ws.path, '/p2.txt'); + ws.destroy(); +} + +// destroy() before any data triggers _destroy + close cleanup +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/d.txt', 'data'); + const rs = myVfs.createReadStream('/d.txt'); + rs.on('error', () => {}); + rs.destroy(); +} diff --git a/test/parallel/test-vfs-symlinks.js b/test/parallel/test-vfs-symlinks.js new file mode 100644 index 00000000000000..f251c08b6b9e33 --- /dev/null +++ b/test/parallel/test-vfs-symlinks.js @@ -0,0 +1,56 @@ +// Flags: --experimental-vfs +'use strict'; + +// Symlink behaviour in the default (memory) VFS: +// - Loop detection (ELOOP) +// - Absolute and relative targets +// - Symlinked parent directories transparently follow + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Direct symlink loop: /a -> /b -> /a +{ + const myVfs = vfs.create(); + myVfs.symlinkSync('/b', '/a'); + myVfs.symlinkSync('/a', '/b'); + assert.throws(() => myVfs.statSync('/a'), { code: 'ELOOP' }); + assert.throws(() => myVfs.realpathSync('/a'), { code: 'ELOOP' }); +} + +// Symlink loop in an intermediate path component +{ + const myVfs = vfs.create(); + myVfs.symlinkSync('/loop2', '/loop1'); + myVfs.symlinkSync('/loop1', '/loop2'); + assert.throws(() => myVfs.statSync('/loop1/sub'), { code: 'ELOOP' }); +} + +// Absolute symlink target inside the VFS +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir'); + myVfs.writeFileSync('/dir/file.txt', 'hi'); + myVfs.symlinkSync('/dir', '/abs-link'); + assert.strictEqual(myVfs.readFileSync('/abs-link/file.txt', 'utf8'), 'hi'); +} + +// Symlinked parent directory transparently follows on read+write +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/real-dir'); + myVfs.writeFileSync('/real-dir/file.txt', 'hello'); + myVfs.symlinkSync('/real-dir', '/link-dir'); + + assert.strictEqual(myVfs.readFileSync('/link-dir/file.txt', 'utf8'), 'hello'); + myVfs.writeFileSync('/link-dir/new.txt', 'new'); + assert.strictEqual(myVfs.readFileSync('/real-dir/new.txt', 'utf8'), 'new'); +} + +// Symlink onto an existing path throws EEXIST +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/a.txt', 'x'); + assert.throws(() => myVfs.symlinkSync('/a.txt', '/a.txt'), { code: 'EEXIST' }); +} diff --git a/test/parallel/test-vfs-truncate-negative.js b/test/parallel/test-vfs-truncate-negative.js new file mode 100644 index 00000000000000..d051d00de8ac9f --- /dev/null +++ b/test/parallel/test-vfs-truncate-negative.js @@ -0,0 +1,15 @@ +// Flags: --experimental-vfs +'use strict'; + +// truncateSync with negative length must clamp to 0, not throw. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'abc'); + +myVfs.truncateSync('/file.txt', -1); +const content = myVfs.readFileSync('/file.txt', 'utf8'); +assert.strictEqual(content, ''); diff --git a/test/parallel/test-vfs-utimes.js b/test/parallel/test-vfs-utimes.js new file mode 100644 index 00000000000000..861b59aaff91d0 --- /dev/null +++ b/test/parallel/test-vfs-utimes.js @@ -0,0 +1,27 @@ +// Flags: --experimental-vfs +'use strict'; + +// utimes / lutimes accept Date instances, numeric seconds, strings, +// and other values (which fall through to a no-op time value). + +require('../common'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/u.txt', 'x'); +myVfs.symlinkSync('/u.txt', '/lk'); + +// Numeric seconds branch +myVfs.utimesSync('/u.txt', 1000, 2000); + +// Date object branch +myVfs.utimesSync('/u.txt', new Date(3000000), new Date(4000000)); + +// String time → DateNow() fallback +myVfs.utimesSync('/u.txt', 'now', 'now'); + +// null/undefined → fallthrough (returns the value as-is) +myVfs.utimesSync('/u.txt', null, undefined); + +// lutimes for symlinks +myVfs.lutimesSync('/lk', new Date(0), new Date(0)); diff --git a/test/parallel/test-vfs-virtual-file-handle.js b/test/parallel/test-vfs-virtual-file-handle.js new file mode 100644 index 00000000000000..21f93709ab65ea --- /dev/null +++ b/test/parallel/test-vfs-virtual-file-handle.js @@ -0,0 +1,88 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +// Cover the VirtualFileHandle base class — all primitives must throw +// ERR_METHOD_NOT_IMPLEMENTED until a subclass overrides them. + +const common = require('../common'); +const assert = require('assert'); +const { VirtualFileHandle } = require('internal/vfs/file_handle'); + +const handle = new VirtualFileHandle('/x.txt', 'r', 0o600); +assert.strictEqual(handle.path, '/x.txt'); +assert.strictEqual(handle.flags, 'r'); +assert.strictEqual(handle.mode, 0o600); +assert.strictEqual(handle.position, 0); +assert.strictEqual(handle.closed, false); + +// Sync stubs throw ERR_METHOD_NOT_IMPLEMENTED +for (const m of ['readSync', 'writeSync', 'readFileSync', 'writeFileSync', + 'statSync', 'truncateSync']) { + assert.throws(() => handle[m](), { code: 'ERR_METHOD_NOT_IMPLEMENTED' }, + `${m} should throw`); +} + +// Async stubs reject +for (const m of ['read', 'write', 'readFile', 'writeFile', 'stat', 'truncate']) { + assert.rejects(handle[m](), { code: 'ERR_METHOD_NOT_IMPLEMENTED' }, + `${m} should reject`).then(common.mustCall()); +} + +// readv/writev base stubs throw +assert.rejects(handle.readv([Buffer.alloc(1)], 0), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }).then(common.mustCall()); +assert.rejects(handle.writev([Buffer.alloc(1)], 0), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }).then(common.mustCall()); + +// appendFile uses write under the hood — same not-implemented +assert.rejects(handle.appendFile('x'), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }).then(common.mustCall()); + +// readableWebStream / readLines / createReadStream / createWriteStream throw +assert.throws(() => handle.readableWebStream(), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }); +assert.throws(() => handle.readLines(), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }); +assert.throws(() => handle.createReadStream(), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }); +assert.throws(() => handle.createWriteStream(), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }); + +// No-op metadata +(async () => { + await handle.chmod(); + await handle.chown(); + await handle.utimes(); + await handle.datasync(); + await handle.sync(); +})().then(common.mustCall()); + +// close() / closeSync() / dispose +{ + const h = new VirtualFileHandle('/y', 'r'); + h.closeSync(); + assert.strictEqual(h.closed, true); + + // Operations after close throw EBADF (via #checkClosed) before NOT_IMPL + assert.throws(() => h.readSync(), { code: 'EBADF' }); + assert.rejects(h.read(), { code: 'EBADF' }).then(common.mustCall()); +} + +// Close via async + Symbol.asyncDispose +(async () => { + const h = new VirtualFileHandle('/z', 'r'); + await h.close(); + assert.strictEqual(h.closed, true); + + const h2 = new VirtualFileHandle('/z2', 'r'); + await h2[Symbol.asyncDispose](); + assert.strictEqual(h2.closed, true); +})().then(common.mustCall()); + +// truncateSync default len = 0 path requires close-check too +{ + const h = new VirtualFileHandle('/a', 'r'); + h.closeSync(); + assert.throws(() => h.truncateSync(), { code: 'EBADF' }); + assert.rejects(h.truncate(), { code: 'EBADF' }).then(common.mustCall()); +} diff --git a/test/parallel/test-vfs-virtual-provider.js b/test/parallel/test-vfs-virtual-provider.js new file mode 100644 index 00000000000000..b30e2665e598f2 --- /dev/null +++ b/test/parallel/test-vfs-virtual-provider.js @@ -0,0 +1,110 @@ +// Flags: --experimental-vfs +'use strict'; + +// Exercise the VirtualProvider base class — its capability flags, +// readonly stubs, and the default implementations built on primitives. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// Bare base provider: every primitive throws ERR_METHOD_NOT_IMPLEMENTED. +{ + const p = new vfs.VirtualProvider(); + assert.strictEqual(p.readonly, false); + assert.strictEqual(p.supportsSymlinks, false); + assert.strictEqual(p.supportsWatch, false); + + for (const method of ['openSync', 'statSync', 'readdirSync', 'mkdirSync', + 'rmdirSync', 'unlinkSync', 'renameSync', + 'linkSync', 'readlinkSync', 'symlinkSync', + 'watch', 'watchAsync', 'watchFile', 'unwatchFile']) { + assert.throws(() => p[method]('/x', '/y'), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }, + `${method} must throw`); + } + + // Async primitives reject with the same error + for (const method of ['open', 'stat', 'readdir', 'mkdir', 'rmdir', 'unlink', + 'rename', 'link', 'readlink', 'symlink']) { + assert.rejects(p[method]('/x', '/y'), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }, + `${method} must reject`).then(common.mustCall()); + } + + // lstat/lstatSync default to stat — should propagate the not-impl error + assert.throws(() => p.lstatSync('/x'), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }); + assert.rejects(p.lstat('/x'), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }).then(common.mustCall()); + + // existsSync / exists default impl: stat throws → false + assert.strictEqual(p.existsSync('/x'), false); + p.exists('/x').then(common.mustCall((r) => { + assert.strictEqual(r, false); + })); +} + +// Read-only provider: write primitives throw EROFS instead of NOT_IMPL. +{ + class RO extends vfs.VirtualProvider { + get readonly() { return true; } + } + const p = new RO(); + for (const method of ['mkdirSync', 'rmdirSync', 'unlinkSync', + 'renameSync', 'linkSync', 'symlinkSync']) { + assert.throws(() => p[method]('/x', '/y'), + { code: 'EROFS' }, + `${method} must throw EROFS`); + } + for (const method of ['mkdir', 'rmdir', 'unlink', 'rename', 'link', 'symlink']) { + assert.rejects(p[method]('/x', '/y'), + { code: 'EROFS' }).then(common.mustCall()); + } + + // copyFile / writeFile / appendFile reject with EROFS + assert.rejects(p.copyFile('/a', '/b'), + { code: 'EROFS' }).then(common.mustCall()); + assert.throws(() => p.copyFileSync('/a', '/b'), + { code: 'EROFS' }); + assert.rejects(p.writeFile('/a', 'x'), + { code: 'EROFS' }).then(common.mustCall()); + assert.throws(() => p.writeFileSync('/a', 'x'), + { code: 'EROFS' }); + assert.rejects(p.appendFile('/a', 'x'), + { code: 'EROFS' }).then(common.mustCall()); + assert.throws(() => p.appendFileSync('/a', 'x'), + { code: 'EROFS' }); +} + +// Default access / realpath / copyFile delegate to stat + read/write +{ + // Use MemoryProvider with the public API to verify delegation paths, + // since the base class needs working primitives. + const myVfs = vfs.create(); + myVfs.writeFileSync('/src.txt', 'hello'); + + // Realpath default: returns path as-is after stat — covered by myVfs.realpathSync + assert.strictEqual(myVfs.realpathSync('/src.txt'), '/src.txt'); + + // exists default impl + assert.strictEqual(myVfs.provider.existsSync('src.txt'), true); + assert.strictEqual(myVfs.provider.existsSync('missing.txt'), false); + + // copyFile via base class default path (MemoryProvider doesn't override) + myVfs.provider.copyFileSync('src.txt', 'dst.txt'); + assert.strictEqual(myVfs.readFileSync('/dst.txt', 'utf8'), 'hello'); + + // copyFile with COPYFILE_EXCL and existing dest must throw + const COPYFILE_EXCL = 1; + assert.throws(() => + myVfs.provider.copyFileSync('src.txt', 'dst.txt', COPYFILE_EXCL), + { code: 'EEXIST' }); + + // accessSync with mode=0 (existence-only) + myVfs.provider.accessSync('src.txt', 0); + + // accessSync R_OK on a readable file should pass + const R_OK = 4; + myVfs.provider.accessSync('src.txt', R_OK); +} diff --git a/test/parallel/test-vfs-watch-abort-signal.js b/test/parallel/test-vfs-watch-abort-signal.js new file mode 100644 index 00000000000000..385eb0da979516 --- /dev/null +++ b/test/parallel/test-vfs-watch-abort-signal.js @@ -0,0 +1,55 @@ +// Flags: --experimental-vfs +'use strict'; + +// AbortSignal handling for watch() and promises.watch(). + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +(async () => { + // Pre-aborted signal closes the watcher at construction + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'a'); + const ac = new AbortController(); + ac.abort(); + const watcher = myVfs.watch('/file.txt', { signal: ac.signal }); + watcher.close(); + } + + // Aborting after construction triggers close + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'a'); + const ac = new AbortController(); + const watcher = myVfs.watch('/file.txt', { signal: ac.signal }); + ac.abort(); + watcher.close(); + } + + // promises.watch with pre-aborted signal resolves done immediately + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/p.txt', 'a'); + const ac = new AbortController(); + ac.abort(); + const iter = myVfs.promises.watch('/p.txt', { signal: ac.signal }); + const r = await iter.next(); + assert.strictEqual(r.done, true); + } + + // promises.watch with mid-flight abort rejects pending next() with AbortError + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/p2.txt', 'a'); + const ac = new AbortController(); + const iter = myVfs.promises.watch('/p2.txt', { + signal: ac.signal, + interval: 1000, + }); + const pending = iter.next(); + queueMicrotask(() => ac.abort()); + await assert.rejects(pending, { name: 'AbortError' }); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-watch-directory.js b/test/parallel/test-vfs-watch-directory.js new file mode 100644 index 00000000000000..03083df1b49aad --- /dev/null +++ b/test/parallel/test-vfs-watch-directory.js @@ -0,0 +1,68 @@ +// Flags: --experimental-vfs +'use strict'; + +// Tests for VFS directory watching: +// - watch() on directories reports child changes +// - File creation / deletion events +// - Listing failures during a poll are swallowed + +const common = require('../common'); +const assert = require('assert'); +const { once } = require('events'); +const vfs = require('node:vfs'); + +// Modifying a child file of a watched directory must emit a change event. +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/parent', { recursive: true }); + myVfs.writeFileSync('/parent/file.txt', 'x'); + + const watcher = myVfs.watch('/parent', { + interval: 50, + persistent: false, + }, common.mustCall((eventType, filename) => { + assert.strictEqual(filename, 'file.txt'); + watcher.close(); + })); + + setTimeout(() => myVfs.writeFileSync('/parent/file.txt', 'y'), 100); +} + +(async () => { + // Non-recursive directory watch: file creation + { + const myVfs = vfs.create(); + myVfs.mkdirSync('/d'); + myVfs.writeFileSync('/d/a.txt', 'x'); + const watcher = myVfs.watch('/d', { interval: 25 }); + const changed = once(watcher, 'change'); + myVfs.writeFileSync('/d/new.txt', 'x'); + await changed; + watcher.close(); + } + + // Non-recursive directory watch: file deletion of a tracked child + { + const myVfs = vfs.create(); + myVfs.mkdirSync('/dd'); + myVfs.writeFileSync('/dd/keep.txt', 'a'); + myVfs.writeFileSync('/dd/goes.txt', 'b'); + const watcher = myVfs.watch('/dd', { interval: 25 }); + const evt = once(watcher, 'change'); + myVfs.unlinkSync('/dd/goes.txt'); + await evt; + watcher.close(); + } + + // The watched directory is removed mid-poll: readdirSync inside the + // poll throws and the watcher swallows the error. + { + const myVfs = vfs.create(); + myVfs.mkdirSync('/gone'); + myVfs.writeFileSync('/gone/f.txt', 'x'); + const watcher = myVfs.watch('/gone', { interval: 25 }); + myVfs.rmSync('/gone', { recursive: true }); + await new Promise((r) => setTimeout(r, 60)); + watcher.close(); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-watch-encoding.js b/test/parallel/test-vfs-watch-encoding.js new file mode 100644 index 00000000000000..be30bc8a35c14c --- /dev/null +++ b/test/parallel/test-vfs-watch-encoding.js @@ -0,0 +1,21 @@ +// Flags: --experimental-vfs +'use strict'; + +// Buffer encoding for watch(): filename arrives as a Buffer. + +const common = require('../common'); +const assert = require('assert'); +const { once } = require('events'); +const vfs = require('node:vfs'); + +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/bf.txt', 'a'); + const watcher = myVfs.watch('/bf.txt', { interval: 25, encoding: 'buffer' }); + const changed = once(watcher, 'change'); + myVfs.writeFileSync('/bf.txt', 'longer-content-changed'); + const [eventType, filename] = await changed; + assert.strictEqual(eventType, 'change'); + assert.ok(Buffer.isBuffer(filename)); + watcher.close(); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-watch-promises.js b/test/parallel/test-vfs-watch-promises.js new file mode 100644 index 00000000000000..87fe500f136ac0 --- /dev/null +++ b/test/parallel/test-vfs-watch-promises.js @@ -0,0 +1,66 @@ +// Flags: --experimental-vfs +'use strict'; + +// promises.watch() returns an async iterable. Cover its event queue, +// next/return/throw, and close-while-pending behaviour. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +(async () => { + // Basic for-await iteration receives a change event + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'a'); + const iter = myVfs.promises.watch('/file.txt', { interval: 25 }); + queueMicrotask(() => myVfs.writeFileSync('/file.txt', 'longer-changed')); + for await (const evt of iter) { + assert.strictEqual(evt.eventType, 'change'); + break; + } + } + + // Events queued before next() drain via the next call + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/q.txt', 'a'); + const iter = myVfs.promises.watch('/q.txt', { interval: 25 }); + myVfs.writeFileSync('/q.txt', 'longer-changed'); + const r = await iter.next(); + if (!r.done) assert.strictEqual(r.value.eventType, 'change'); + await iter.return(); + } + + // A change while a next() is pending shifts the resolver + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/q2.txt', 'a'); + const iter = myVfs.promises.watch('/q2.txt', { interval: 25 }); + const pending = iter.next(); + myVfs.writeFileSync('/q2.txt', 'longer-changed'); + const r = await pending; + if (!r.done) assert.strictEqual(r.value.eventType, 'change'); + await iter.return(); + } + + // throw() closes the watcher and resolves with done:true + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/q3.txt', 'a'); + const iter = myVfs.promises.watch('/q3.txt', { interval: 1000 }); + const r = await iter.throw(new Error('go away')); + assert.strictEqual(r.done, true); + } + + // Close while a resolver is pending — drains via the 'close' handler + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/q4.txt', 'a'); + const iter = myVfs.promises.watch('/q4.txt', { interval: 1000 }); + const pending = iter.next(); + queueMicrotask(() => iter.return()); + const r = await pending; + assert.strictEqual(r.done, true); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-watch-recursive.js b/test/parallel/test-vfs-watch-recursive.js new file mode 100644 index 00000000000000..9250660285ff5a --- /dev/null +++ b/test/parallel/test-vfs-watch-recursive.js @@ -0,0 +1,34 @@ +// Flags: --experimental-vfs +'use strict'; + +// Recursive directory watching: descendant changes trigger events. + +const common = require('../common'); +const { once } = require('events'); +const vfs = require('node:vfs'); + +(async () => { + // Recursive watch detects creation in a subdirectory + { + const myVfs = vfs.create(); + myVfs.mkdirSync('/d/sub', { recursive: true }); + myVfs.writeFileSync('/d/sub/a.txt', 'x'); + const watcher = myVfs.watch('/d', { interval: 25, recursive: true }); + const changed = once(watcher, 'change'); + myVfs.writeFileSync('/d/sub/b.txt', 'new'); + await changed; + watcher.close(); + } + + // Recursive watch detects modification of a pre-existing descendant + { + const myVfs = vfs.create(); + myVfs.mkdirSync('/r/sub', { recursive: true }); + myVfs.writeFileSync('/r/sub/file.txt', 'x'); + const watcher = myVfs.watch('/r', { interval: 25, recursive: true }); + const changed = once(watcher, 'change'); + myVfs.writeFileSync('/r/sub/file.txt', 'longer-content-changed'); + await changed; + watcher.close(); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-watch.js b/test/parallel/test-vfs-watch.js new file mode 100644 index 00000000000000..7db8bad4eddb89 --- /dev/null +++ b/test/parallel/test-vfs-watch.js @@ -0,0 +1,75 @@ +// Flags: --experimental-vfs +'use strict'; + +// Tests for VFS watch() on a single file: change detection, listener +// registration, ref/unref, and the watch-then-create flow. + +const common = require('../common'); +const assert = require('assert'); +const { once } = require('events'); +const vfs = require('node:vfs'); + +(async () => { + // Listener as 2nd argument + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/lf.txt', 'a'); + const w = myVfs.watch('/lf.txt', () => {}); + w.close(); + } + + // Listener add/remove + ref/unref smoke + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/r.txt', 'a'); + const w = myVfs.watch('/r.txt'); + const fn = () => {}; + w.on('change', fn); + w.removeListener('change', fn); + w.on('change', fn); + w.removeAllListeners('change'); + w.ref(); + w.unref(); + w.close(); + } + + // Double close is a no-op + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/x.txt', 'a'); + const watcher = myVfs.watch('/x.txt'); + watcher.close(); + watcher.close(); + } + + // persistent: false reaches the unref branch + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/p.txt', 'a'); + const watcher = myVfs.watch('/p.txt', { persistent: false }); + watcher.close(); + } + + // Watching a missing path then creating it + { + const myVfs = vfs.create(); + const watcher = myVfs.watch('/late.txt', { interval: 25 }); + const changed = once(watcher, 'change'); + myVfs.writeFileSync('/late.txt', 'now'); + await changed; + watcher.close(); + } + + // Modifying the watched file emits change with the basename as filename + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/single.txt', 'a'); + const watcher = myVfs.watch('/single.txt', { interval: 25 }); + const evt = once(watcher, 'change'); + myVfs.writeFileSync('/single.txt', 'longer-content-changed'); + const [eventType, filename] = await evt; + assert.strictEqual(eventType, 'change'); + assert.strictEqual(filename, 'single.txt'); + watcher.close(); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-watchfile.js b/test/parallel/test-vfs-watchfile.js new file mode 100644 index 00000000000000..6d3c28875552da --- /dev/null +++ b/test/parallel/test-vfs-watchfile.js @@ -0,0 +1,111 @@ +// Flags: --experimental-vfs +'use strict'; + +// Tests for VFS watchFile/unwatchFile. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// unwatchFile(path) without a specific listener cleans up the timer. +// If the timer leaks, the process would hang. +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/a.txt', 'x'); + myVfs.watchFile('/a.txt', { interval: 50, persistent: false }, () => {}); + myVfs.unwatchFile('/a.txt'); +} + +// Default options: no interval option provided +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/dw.txt', 'a'); + const listener = () => {}; + myVfs.watchFile('/dw.txt', listener); + myVfs.unwatchFile('/dw.txt', listener); +} + +// Listener as 2nd argument (no options object) +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/lf.txt', 'a'); + const listener = () => {}; + myVfs.watchFile('/lf.txt', listener); + myVfs.unwatchFile('/lf.txt', listener); +} + +// Double unwatch is a no-op +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/sw.txt', 'a'); + const listener = () => {}; + myVfs.watchFile('/sw.txt', { interval: 25 }, listener); + myVfs.unwatchFile('/sw.txt', listener); + myVfs.unwatchFile('/sw.txt', listener); +} + +// Zero stats for a missing file: prev.isFile() is false and prev.mode is 0 +{ + const myVfs = vfs.create(); + function listener(curr, prev) { + assert.strictEqual(prev.isFile(), false); + assert.strictEqual(prev.mode, 0); + myVfs.unwatchFile('/missing.txt', listener); + } + myVfs.watchFile('/missing.txt', { interval: 50, persistent: false }, listener); + setTimeout(() => myVfs.writeFileSync('/missing.txt', 'x'), 100); +} + +// Content change fires the listener with curr/prev stats +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/sw.txt', 'a'); + const listener = common.mustCallAtLeast((curr, prev) => { + assert.strictEqual(typeof curr.size, 'number'); + assert.strictEqual(typeof prev.size, 'number'); + }, 1); + const fired = new Promise((resolve) => { + myVfs.watchFile('/sw.txt', { interval: 25 }, (curr, prev) => { + listener(curr, prev); + resolve(); + }); + }); + myVfs.writeFileSync('/sw.txt', 'longer-content-changed'); + await fired; + myVfs.unwatchFile('/sw.txt'); +})().then(common.mustCall()); + +// bigint: true returns BigInt fields in both curr and prev stats, plus +// the bigint createZeroStats path when watching an initially-missing file. +(async () => { + const myVfs = vfs.create(); + myVfs.writeFileSync('/bi.txt', 'a'); + const listener = common.mustCallAtLeast((curr, prev) => { + assert.strictEqual(typeof curr.size, 'bigint'); + assert.strictEqual(typeof prev.size, 'bigint'); + }, 1); + const fired = new Promise((resolve) => { + myVfs.watchFile('/bi.txt', { interval: 25, bigint: true }, (curr, prev) => { + listener(curr, prev); + resolve(); + }); + }); + myVfs.writeFileSync('/bi.txt', 'longer-content-changed'); + await fired; + myVfs.unwatchFile('/bi.txt'); +})().then(common.mustCall()); + +// bigint: true on a missing file emits BigInt prev.size = 0n +{ + const myVfs = vfs.create(); + const watcher = myVfs.watchFile('/missing-b.txt', + { interval: 50, persistent: false, bigint: true }, + common.mustCallAtLeast((curr, prev) => { + assert.strictEqual(typeof curr.size, 'bigint'); + assert.strictEqual(typeof prev.size, 'bigint'); + myVfs.unwatchFile('/missing-b.txt'); + }, 1)); + setTimeout(() => myVfs.writeFileSync('/missing-b.txt', 'now-here'), 80); + setTimeout(() => myVfs.unwatchFile('/missing-b.txt'), 500); + if (watcher?.unref) watcher.unref(); +} diff --git a/test/parallel/test-vfs-write-options.js b/test/parallel/test-vfs-write-options.js new file mode 100644 index 00000000000000..476d00801bdd06 --- /dev/null +++ b/test/parallel/test-vfs-write-options.js @@ -0,0 +1,33 @@ +// Flags: --experimental-vfs +'use strict'; + +// writeFile / appendFile accept explicit { flag, mode } options. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// writeFileSync / promises.writeFile with explicit flag and mode +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/a.txt', 'hello', { flag: 'w', mode: 0o600 }); + assert.strictEqual(myVfs.readFileSync('/a.txt', 'utf8'), 'hello'); + + myVfs.promises.writeFile('/b.txt', 'world', { flag: 'w', mode: 0o600 }) + .then(common.mustCall(() => { + assert.strictEqual(myVfs.readFileSync('/b.txt', 'utf8'), 'world'); + })); +} + +// appendFileSync / promises.appendFile with explicit flag and mode +{ + const myVfs = vfs.create(); + myVfs.appendFileSync('/a.txt', 'first', { flag: 'a', mode: 0o600 }); + myVfs.appendFileSync('/a.txt', '-second', { flag: 'a' }); + assert.strictEqual(myVfs.readFileSync('/a.txt', 'utf8'), 'first-second'); + + myVfs.promises.appendFile('/b.txt', 'go', { flag: 'a', mode: 0o600 }) + .then(common.mustCall(() => { + assert.strictEqual(myVfs.readFileSync('/b.txt', 'utf8'), 'go'); + })); +}