From 5bbcbad36c23309e1e4c4b24ecf8b5f25996529b Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 30 Apr 2026 15:33:55 +0200 Subject: [PATCH 01/14] vfs: add minimal node:vfs subsystem Adds the node:vfs builtin module with VirtualFileSystem and provider classes. No integration with fs, modules, or SEA. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- doc/api/index.md | 1 + doc/api/vfs.md | 1060 +++++++++++++++ lib/internal/bootstrap/realm.js | 1 + lib/internal/vfs/dir.js | 109 ++ lib/internal/vfs/errors.js | 211 +++ lib/internal/vfs/fd.js | 87 ++ lib/internal/vfs/file_handle.js | 732 +++++++++++ lib/internal/vfs/file_system.js | 1147 +++++++++++++++++ lib/internal/vfs/provider.js | 618 +++++++++ lib/internal/vfs/providers/memory.js | 1024 +++++++++++++++ lib/internal/vfs/providers/real.js | 492 +++++++ lib/internal/vfs/stats.js | 300 +++++ lib/internal/vfs/streams.js | 357 +++++ lib/internal/vfs/watcher.js | 688 ++++++++++ lib/vfs.js | 37 + test/parallel/test-vfs-append-write.js | 18 + test/parallel/test-vfs-ctime-update.js | 48 + test/parallel/test-vfs-fd.js | 318 +++++ test/parallel/test-vfs-promises.js | 491 +++++++ .../test-vfs-readdir-symlink-recursive.js | 21 + test/parallel/test-vfs-readfile-flag.js | 38 + test/parallel/test-vfs-stats-ino-dev.js | 23 + test/parallel/test-vfs-stream-properties.js | 39 + test/parallel/test-vfs-streams.js | 212 +++ test/parallel/test-vfs-watch-directory.js | 50 + test/parallel/test-vfs-watchfile.js | 35 + 26 files changed, 8157 insertions(+) create mode 100644 doc/api/vfs.md create mode 100644 lib/internal/vfs/dir.js create mode 100644 lib/internal/vfs/errors.js create mode 100644 lib/internal/vfs/fd.js create mode 100644 lib/internal/vfs/file_handle.js create mode 100644 lib/internal/vfs/file_system.js create mode 100644 lib/internal/vfs/provider.js create mode 100644 lib/internal/vfs/providers/memory.js create mode 100644 lib/internal/vfs/providers/real.js create mode 100644 lib/internal/vfs/stats.js create mode 100644 lib/internal/vfs/streams.js create mode 100644 lib/internal/vfs/watcher.js create mode 100644 lib/vfs.js create mode 100644 test/parallel/test-vfs-append-write.js create mode 100644 test/parallel/test-vfs-ctime-update.js create mode 100644 test/parallel/test-vfs-fd.js create mode 100644 test/parallel/test-vfs-promises.js create mode 100644 test/parallel/test-vfs-readdir-symlink-recursive.js create mode 100644 test/parallel/test-vfs-readfile-flag.js create mode 100644 test/parallel/test-vfs-stats-ino-dev.js create mode 100644 test/parallel/test-vfs-stream-properties.js create mode 100644 test/parallel/test-vfs-streams.js create mode 100644 test/parallel/test-vfs-watch-directory.js create mode 100644 test/parallel/test-vfs-watchfile.js diff --git a/doc/api/index.md b/doc/api/index.md index 1c9cc02c6d80a7..1ba6d58312851a 100644 --- a/doc/api/index.md +++ b/doc/api/index.md @@ -65,6 +65,7 @@ * [URL](url.md) * [Utilities](util.md) * [V8](v8.md) +* [Virtual File System](vfs.md) * [VM](vm.md) * [WASI](wasi.md) * [Web Crypto API](webcrypto.md) diff --git a/doc/api/vfs.md b/doc/api/vfs.md new file mode 100644 index 00000000000000..206495f0d73c10 --- /dev/null +++ b/doc/api/vfs.md @@ -0,0 +1,1060 @@ +# Virtual File System + + + + + +> Stability: 1 - Experimental + + + +The `node:vfs` module provides a virtual file system that can be mounted +alongside the real file system. Virtual files can be read using standard `node:fs` +operations and loaded as modules using `require()` or `import`. + +To access it: + +```mjs +import vfs from 'node:vfs'; +``` + +```cjs +const vfs = require('node:vfs'); +``` + +This module is only available under the `node:` scheme. + +## Overview + +The Virtual File System (VFS) allows you to create in-memory file systems that +integrate seamlessly with the Node.js `node:fs` module and module loading system. This +is useful for: + +* Bundling assets in Single Executable Applications (SEA) +* Testing file system operations without touching the disk +* Creating virtual module systems +* Embedding configuration or data files in applications + +## Operating modes + +The VFS supports two operating modes: + +### Standard mode (default) + +When mounted at a path prefix (e.g., `/virtual`), the VFS handles **all** +operations for paths starting with that prefix. The VFS completely shadows +any real file system paths under the mount point. + +### Overlay mode + +When created with `{ overlay: true }`, the VFS selectively intercepts only +paths that exist within the VFS. Paths that don't exist in the VFS fall through +to the real file system. This is useful for mocking specific files while leaving +others unchanged. + +```cjs +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +// Overlay mode: only intercept files that exist in VFS +const myVfs = vfs.create({ overlay: true }); +myVfs.writeFileSync('/etc/config.json', JSON.stringify({ mocked: true })); +myVfs.mount('/'); + +// This reads from VFS (file exists in VFS) +fs.readFileSync('/etc/config.json', 'utf8'); // '{"mocked": true}' + +// This reads from real FS (file doesn't exist in VFS) +fs.readFileSync('/etc/hostname', 'utf8'); // Real file content +``` + +See [Security considerations][] for important warnings about overlay mode. + +## Debugging + +Set `NODE_DEBUG=vfs` to log VFS mount, routing, and module-loading decisions to +`stderr`. + +```console +$ NODE_DEBUG=vfs node app.js +VFS 12345: mount /virtual overlay=false moduleHooks=true virtualCwd=false +VFS 12345: register mount=/virtual overlay=false active=1 +VFS 12345: read /virtual/app/config.json -> hit (mount=/virtual overlay=false) +``` + +## Basic usage + +The following example shows how to create a virtual file system, add files, +and access them through the standard `node:fs` API: + +```mjs +import vfs from 'node:vfs'; +import fs from 'node:fs'; + +// Create a new virtual file system +const myVfs = vfs.create(); + +// Create directories and files +myVfs.mkdirSync('/app'); +myVfs.writeFileSync('/app/config.json', JSON.stringify({ port: 3000 })); +myVfs.writeFileSync('/app/greet.js', 'module.exports = (name) => "Hello, " + name + "!";'); + +// Mount the VFS at a path prefix +myVfs.mount('/virtual'); + +// Now standard fs operations work on the virtual files +const config = JSON.parse(fs.readFileSync('/virtual/app/config.json', 'utf8')); +console.log(config.port); // 3000 + +// Modules can be required from the VFS +const greet = await import('/virtual/app/greet.js'); +console.log(greet.default('World')); // Hello, World! + +// Clean up +myVfs.unmount(); +``` + +```cjs +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +// Create a new virtual file system +const myVfs = vfs.create(); + +// Create directories and files +myVfs.mkdirSync('/app'); +myVfs.writeFileSync('/app/config.json', JSON.stringify({ port: 3000 })); +myVfs.writeFileSync('/app/greet.js', 'module.exports = (name) => "Hello, " + name + "!";'); + +// Mount the VFS at a path prefix +myVfs.mount('/virtual'); + +// Now standard fs operations work on the virtual files +const config = JSON.parse(fs.readFileSync('/virtual/app/config.json', 'utf8')); +console.log(config.port); // 3000 + +// Modules can be required from the VFS +const greet = require('/virtual/app/greet.js'); +console.log(greet('World')); // Hello, World! + +// Clean up +myVfs.unmount(); +``` + +## Limitations + +The VFS has the following limitations: + +### Native addons + +Native addons (`.node` files) cannot be loaded from the VFS. Native addons +must exist on the real file system because they are loaded by the operating +system's dynamic linker, which cannot access virtual files. + +### Child processes + +Other processes, including any child processes of the Node.js process, cannot +access virtual file systems. Node.js child processes do not inherit the +parent's VFS mounts. + +### Worker threads + +Each worker thread has its own independent VFS state. A VFS mounted in the +main thread is not automatically available in worker threads. To use VFS in +workers, create and mount a new VFS instance within each worker. + +### `fs.watch` limitations + +The `fs.watch()` and `fs.watchFile()` functions work with VFS files but use +polling internally rather than native file system notifications, since VFS +files exist only in memory. + +### Code caching in SEA + +When using VFS with Single Executable Applications, the `useCodeCache` option +in the SEA configuration does not currently apply to modules loaded from the +VFS. This is a current limitation due to incomplete implementation, not a +technical impossibility. Consider bundling the application to enable code +caching and do not rely on module loading in VFS. + +## `vfs.create([provider][, options])` + + + +* `provider` {VirtualProvider} Optional provider instance. Defaults to a new + `MemoryProvider`. +* `options` {Object} + * `moduleHooks` {boolean} Whether to enable `require()`/`import` hooks for + loading modules from the VFS. **Default:** `true`. + * `virtualCwd` {boolean} Whether to enable virtual working directory support. + **Default:** `false`. + * `overlay` {boolean} Whether to enable overlay mode. In overlay mode, the VFS + only intercepts paths that exist in the VFS, allowing other paths to fall + through to the real file system. Useful for mocking specific files while + leaving others unchanged. See [Security considerations][] for important + warnings. **Default:** `false`. +* Returns: {VirtualFileSystem} + +Creates a new `VirtualFileSystem` instance. If no provider is specified, a +`MemoryProvider` is used, which stores files in memory. + +```mjs +import vfs from 'node:vfs'; + +// Create with default MemoryProvider +const memoryVfs = vfs.create(); + +// Create with explicit provider +const customVfs = vfs.create(new vfs.MemoryProvider()); + +// Create with options only +const vfsWithOptions = vfs.create({ moduleHooks: false }); +``` + +```cjs +const vfs = require('node:vfs'); + +// Create with default MemoryProvider +const memoryVfs = vfs.create(); + +// Create with explicit provider +const customVfs = vfs.create(new vfs.MemoryProvider()); + +// Create with options only +const vfsWithOptions = vfs.create({ moduleHooks: false }); +``` + +## Class: `VirtualFileSystem` + + + +The `VirtualFileSystem` class provides a file system interface backed by a +provider. It supports standard file system operations and can be mounted to +make virtual files accessible through the `node:fs` module. + +### `new VirtualFileSystem([provider][, options])` + + + +* `provider` {VirtualProvider} The provider to use. **Default:** `MemoryProvider`. +* `options` {Object} + * `moduleHooks` {boolean} Enable module loading hooks. **Default:** `true`. + * `virtualCwd` {boolean} Enable virtual working directory. **Default:** `false`. + +Creates a new `VirtualFileSystem` instance. + +Multiple `VirtualFileSystem` instances can be created and used independently. +Each instance maintains its own file tree and can be mounted at different +paths. However, only one VFS can be mounted at a given path prefix at a time. +If two VFS instances are mounted at overlapping paths (e.g., `/virtual` and +`/virtual/sub`), the more specific path takes precedence for matching paths. + +### `vfs.chdir(path)` + + + +* `path` {string} The new working directory path within the VFS. + +Changes the virtual working directory. This only affects path resolution within +the VFS when `virtualCwd` is enabled in the constructor options. + +Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. + +When mounted with `virtualCwd` enabled, the VFS also hooks `process.chdir()` and +`process.cwd()` to support virtual paths transparently. In Worker threads, +`process.chdir()` to virtual paths will work, but attempting to change to real +file system paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. + +### `vfs.cwd()` + + + +* Returns: {string|null} + +Returns the current virtual working directory, or `null` if no virtual directory +has been set yet. + +Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. + +### `vfs.mount(prefix)` + + + +* `prefix` {string} The path prefix where the VFS will be mounted. +* Returns: {VirtualFileSystem} The VFS instance (for chaining or `using`). + +Mounts the virtual file system at the specified path prefix. After mounting, +files in the VFS can be accessed via the `node:fs` module using paths that start +with the prefix. + +If a real file system path already exists at the mount prefix, the VFS +**shadows** that path. All operations to paths under the mount prefix will be +directed to the VFS, making the real files inaccessible until the VFS is +unmounted. See [Security considerations][] for important warnings about this +behavior. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/data.txt', 'Hello'); +myVfs.mount('/virtual'); + +// Now accessible as /virtual/data.txt +require('node:fs').readFileSync('/virtual/data.txt', 'utf8'); // 'Hello' +``` + +On Windows, mount paths use drive letters: + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/data.txt', 'Hello'); +myVfs.mount('C:\\virtual'); + +// Now accessible as C:\virtual\data.txt +require('node:fs').readFileSync('C:\\virtual\\data.txt', 'utf8'); // 'Hello' +``` + +The VFS supports the [Explicit Resource Management][] proposal. Use the `using` +declaration to automatically unmount when leaving scope: + +```cjs +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +{ + using myVfs = vfs.create(); + myVfs.writeFileSync('/data.txt', 'Hello'); + myVfs.mount('/virtual'); + + fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello' +} // VFS is automatically unmounted here + +fs.existsSync('/virtual/data.txt'); // false - VFS is unmounted +``` + +### `vfs.mounted` + + + +* {boolean} + +Returns `true` if the VFS is currently mounted. + +### `vfs.mountPoint` + + + +* {string | null} + +The current mount point as an absolute path, or `null` if not mounted. + +### `vfs.overlay` + + + +* {boolean} + +Returns `true` if overlay mode is enabled. In overlay mode, the VFS only +intercepts paths that exist in the VFS, allowing other paths to fall through +to the real file system. + +### `vfs.provider` + + + +* {VirtualProvider} + +The underlying provider for this VFS instance. Can be used to access +provider-specific methods like `setReadOnly()` for `MemoryProvider`. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Access the provider +console.log(myVfs.provider.readonly); // false +myVfs.provider.setReadOnly(); +console.log(myVfs.provider.readonly); // true +``` + +### `vfs.readonly` + + + +* {boolean} + +Returns `true` if the underlying provider is read-only. + +### `vfs.unmount()` + + + +Unmounts the virtual file system. After unmounting, virtual files are no longer +accessible through the `node:fs` module. The VFS can be remounted at the same or a +different path by calling `mount()` again. Unmounting also resets the virtual +working directory if one was set. + +This method is idempotent: calling `unmount()` on an already unmounted VFS +has no effect. + +### File System Methods + +The `VirtualFileSystem` class provides methods that mirror the `node:fs` module API. +All paths are relative to the VFS root (not the mount point). + +These methods accept the same argument types as their `node:fs` counterparts, +including `string`, `Buffer`, `TypedArray`, and `DataView` where applicable. + +#### Overlay mode behavior + +When overlay mode is enabled, the following behavior applies to `node:fs` operations +on mounted paths. + +**Path encoding:** The VFS uses UTF-8 encoding for file and directory names +internally. In overlay mode, path matching is performed using the VFS's UTF-8 +encoding. When falling through to the real file system, paths are passed to +the native file system APIs which handle encoding according to platform +conventions (UTF-8 on most Unix systems, UTF-16 on Windows). This means the +VFS inherits the underlying file system's encoding behavior for paths that +fall through, while VFS-internal paths always use UTF-8. + +**Case sensitivity:** The VFS is always case-sensitive internally. In overlay +mode, this can cause unexpected behavior when overlaying a case-insensitive +file system (such as macOS HFS+ or Windows NTFS): + +* A VFS file at `/Data.txt` will not shadow a real file at `/data.txt` +* Looking up `/DATA.TXT` will fall through to the real file system (not found + in case-sensitive VFS), potentially finding a real file with different casing +* This mismatch is intentional: the VFS maintains consistent cross-platform + behavior rather than emulating the underlying file system's case handling + +If case-insensitive matching is required, applications should normalize paths +before VFS operations. + +**Operation routing:** + +* **Read operations** (`readFile`, `readdir`, `stat`, `lstat`, `access`, + `exists`, `realpath`, `readlink`, `statfs`, `opendir`): Check VFS first. If + the path doesn't exist in VFS, fall through to the real file system. +* **Write operations** (`writeFile`, `appendFile`, `mkdir`, `rename`, `unlink`, + `rmdir`, `symlink`, `copyFile`, `truncate`, `link`, `chmod`, `chown`, + `utimes`, `lutimes`, `mkdtemp`, `rm`, `cp`): Always operate on VFS. New + files are created in VFS, and attempting to modify a real file that doesn't + exist in VFS will create a new VFS file instead. +* **File descriptors**: Once a file is opened, all subsequent operations on that + descriptor stay within the same layer (VFS or real FS) where it was opened. + +#### Synchronous Methods + +The `VirtualFileSystem` class supports all common synchronous `node:fs` methods +for reading, writing, and managing files and directories. Methods mirror the +`node:fs` module API. + +#### Promise Methods + +All synchronous methods have promise-based equivalents available through +`vfs.promises`: + +```mjs +import vfs from 'node:vfs'; + +const myVfs = vfs.create(); + +await myVfs.promises.writeFile('/data.txt', 'Hello'); +const content = await myVfs.promises.readFile('/data.txt', 'utf8'); +console.log(content); // 'Hello' +``` + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +async function example() { + await myVfs.promises.writeFile('/data.txt', 'Hello'); + const content = await myVfs.promises.readFile('/data.txt', 'utf8'); + console.log(content); // 'Hello' +} +``` + +## Class: `VirtualProvider` + + + +The `VirtualProvider` class is an abstract base class for VFS providers. +Providers implement the actual file system storage and operations. + +### `provider.readonly` + + + +* {boolean} + +Returns `true` if the provider is read-only. + +### `provider.supportsSymlinks` + + + +* {boolean} + +Returns `true` if the provider supports symbolic links. + +### `provider.supportsWatch` + + + +* {boolean} + +Returns `true` if the provider supports file watching via `watch()`, +`watchFile()`, and `unwatchFile()`. + +### Creating Custom Providers + +To create a custom provider, extend `VirtualProvider` and implement the +required methods: + +```cjs +const { VirtualProvider } = require('node:vfs'); + +class MyProvider extends VirtualProvider { + get readonly() { return false; } + get supportsSymlinks() { return true; } + + openSync(path, flags, mode) { + // Implementation + } + + statSync(path, options) { + // Implementation + } + + readdirSync(path, options) { + // Implementation + } + + // ... implement other required methods +} +``` + +## Class: `MemoryProvider` + + + +The `MemoryProvider` stores files in memory. It supports full read/write +operations and symbolic links. + +```cjs +const { create, MemoryProvider } = require('node:vfs'); + +const myVfs = create(new MemoryProvider()); +``` + +### `memoryProvider.setReadOnly()` + + + +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. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Populate the VFS +myVfs.mkdirSync('/app'); +myVfs.writeFileSync('/app/config.json', '{"readonly": true}'); + +// Make it read-only +myVfs.provider.setReadOnly(); + +// This would now throw an error +// myVfs.writeFileSync('/app/config.json', 'new content'); +``` + +## Class: `RealFSProvider` + + + +The `RealFSProvider` wraps a real file system directory, allowing it to be +mounted at a different VFS path. This is useful for: + +* Mounting a directory at a different path +* Enabling `virtualCwd` support in Worker threads (by mounting the real + file system through VFS) +* Creating sandboxed views of real directories + +### `new RealFSProvider(rootPath)` + + + +* `rootPath` {string} The real file system path to use as the provider root. + +Creates a new `RealFSProvider` that wraps the specified directory. All paths +accessed through this provider are resolved relative to `rootPath`. Path +traversal outside `rootPath` (via `..`) is prevented for security. + +```mjs +import vfs from 'node:vfs'; + +// Mount /home/user/project at /project +const projectVfs = vfs.create(new vfs.RealFSProvider('/home/user/project')); +projectVfs.mount('/project'); + +// Now /project/src/index.js maps to /home/user/project/src/index.js +import fs from 'node:fs'; +const content = fs.readFileSync('/project/src/index.js', 'utf8'); +``` + +```cjs +const vfs = require('node:vfs'); + +// Mount /home/user/project at /project +const projectVfs = vfs.create(new vfs.RealFSProvider('/home/user/project')); +projectVfs.mount('/project'); + +// Now /project/src/index.js maps to /home/user/project/src/index.js +const fs = require('node:fs'); +const content = fs.readFileSync('/project/src/index.js', 'utf8'); +``` + +### `realFSProvider.rootPath` + + + +* {string} + +The real file system path that this provider wraps. + +## Integration with `node:fs` module + +When a VFS is mounted, the standard `node:fs` module automatically routes operations +to the VFS for paths that match the mount prefix: + +```mjs +import vfs from 'node:vfs'; +import fs from 'node:fs'; + +const myVfs = vfs.create(); +myVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); +myVfs.mount('/virtual'); + +// These all work transparently +fs.readFileSync('/virtual/hello.txt', 'utf8'); // Sync +await fs.promises.readFile('/virtual/hello.txt', 'utf8'); // Promise +fs.createReadStream('/virtual/hello.txt'); // Stream + +// Real file system is still accessible +fs.readFileSync('/etc/passwd'); // Real file +``` + +```cjs +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); +myVfs.mount('/virtual'); + +// These all work transparently +fs.readFileSync('/virtual/hello.txt', 'utf8'); // Sync +fs.promises.readFile('/virtual/hello.txt', 'utf8'); // Promise +fs.createReadStream('/virtual/hello.txt'); // Stream + +// Real file system is still accessible +fs.readFileSync('/etc/passwd'); // Real file +``` + +### Intercepted `node:fs` methods + +The following `node:fs` methods are intercepted when a VFS is mounted. Each +method is intercepted in its synchronous, callback, and/or promise form. + +**Path-based read operations** (synchronous, callback, and promise): + +* `existsSync()`, `exists()` +* `statSync()`, `stat()`, `fs.promises.stat()` +* `lstatSync()`, `lstat()`, `fs.promises.lstat()` +* `readFileSync()`, `readFile()`, `fs.promises.readFile()` +* `readdirSync()`, `readdir()`, `fs.promises.readdir()` +* `realpathSync()`, `realpath()`, `fs.promises.realpath()` +* `accessSync()`, `access()`, `fs.promises.access()` +* `readlinkSync()`, `readlink()`, `fs.promises.readlink()` +* `statfsSync()`, `statfs()`, `fs.promises.statfs()` +* `opendirSync()`, `opendir()` + +**Path-based write operations** (synchronous, callback, and promise): + +* `writeFileSync()`, `writeFile()`, `fs.promises.writeFile()` +* `appendFileSync()`, `appendFile()`, `fs.promises.appendFile()` +* `mkdirSync()`, `mkdir()`, `fs.promises.mkdir()` +* `rmdirSync()`, `rmdir()`, `fs.promises.rmdir()` +* `rmSync()`, `rm()`, `fs.promises.rm()` +* `unlinkSync()`, `unlink()`, `fs.promises.unlink()` +* `renameSync()`, `rename()`, `fs.promises.rename()` +* `copyFileSync()`, `copyFile()`, `fs.promises.copyFile()` +* `symlinkSync()`, `symlink()`, `fs.promises.symlink()` +* `truncateSync()`, `truncate()`, `fs.promises.truncate()` +* `linkSync()`, `link()`, `fs.promises.link()` +* `chmodSync()`, `chmod()`, `fs.promises.chmod()` +* `chownSync()`, `chown()`, `fs.promises.chown()` +* `lchownSync()`, `lchown()`, `fs.promises.lchown()` +* `utimesSync()`, `utimes()`, `fs.promises.utimes()` +* `lutimesSync()`, `lutimes()`, `fs.promises.lutimes()` +* `mkdtempSync()`, `mkdtemp()`, `fs.promises.mkdtemp()` +* `lchmod()`, `fs.promises.lchmod()` +* `cpSync()`, `cp()`, `fs.promises.cp()` + +**File descriptor operations** (synchronous and callback): + +* `openSync()`, `open()` +* `closeSync()`, `close()` +* `readSync()`, `read()` +* `writeSync()`, `write()` +* `readvSync()`, `readv()` +* `writevSync()`, `writev()` +* `fstatSync()`, `fstat()` +* `ftruncateSync()`, `ftruncate()` +* `fchmodSync()`, `fchmod()` (no-op for VFS file descriptors) +* `fchownSync()`, `fchown()` (no-op for VFS file descriptors) +* `futimesSync()`, `futimes()` (no-op for VFS file descriptors) +* `fdatasyncSync()`, `fdatasync()` (no-op for VFS file descriptors) +* `fsyncSync()`, `fsync()` (no-op for VFS file descriptors) + +Virtual file descriptors use a bitmask (`0x40000000`) to avoid conflicts with +real file descriptors while remaining valid positive integers. + +**Stream operations**: + +* `createReadStream()` +* `createWriteStream()` + +**Watch operations**: + +* `watch()`, `fs.promises.watch()` +* `watchFile()` +* `unwatchFile()` + +### `node:fs` methods with no VFS equivalent + +The following `node:fs` methods are **not** intercepted and always operate on +the real file system: + +* `glob()`, `globSync()` + +## Integration with module loading + +Virtual files can be loaded as modules using `require()` or `import`: + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/math.js', ` + exports.add = (a, b) => a + b; + exports.multiply = (a, b) => a * b; +`); +myVfs.mount('/modules'); + +const math = require('/modules/math.js'); +console.log(math.add(2, 3)); // 5 +``` + +```mjs +import vfs from 'node:vfs'; + +const myVfs = vfs.create(); +myVfs.writeFileSync('/greet.mjs', ` + export default function greet(name) { + return \`Hello, \${name}!\`; + } +`); +myVfs.mount('/modules'); + +const { default: greet } = await import('/modules/greet.mjs'); +console.log(greet('World')); // Hello, World! +``` + +## Implementation details + +### `Stats` objects + +The VFS returns real {fs.Stats} objects from `stat()`, `lstat()`, and `fstat()` +operations. These `Stats` objects behave identically to those returned by the real +file system: + +* `stats.isFile()`, `stats.isDirectory()`, `stats.isSymbolicLink()` work correctly +* `stats.size` reflects the actual content size +* `stats.mtime`, `stats.ctime`, `stats.birthtime` are tracked per file +* `stats.mode` includes the file type bits and permissions + +## Use with Single Executable Applications + +When running as a Single Executable Application (SEA) with `"useVfs": true` in +the SEA configuration, bundled assets are automatically mounted at `/sea`. No +additional setup is required. + +`"useVfs"` cannot be used together with `"useSnapshot"`, `"useCodeCache"`, or +`"mainFormat": "module"`. The SEA configuration parser will error if any of +these combinations are detected. + +```cjs +// In your SEA entry script +const fs = require('node:fs'); + +// Access bundled assets directly - they are automatically available at /sea +const config = JSON.parse(fs.readFileSync('/sea/config.json', 'utf8')); +const template = fs.readFileSync('/sea/templates/index.html', 'utf8'); +``` + +See the [Single Executable Applications][] documentation for more information +on creating SEA builds with assets. + +## Symbolic links + +The VFS supports symbolic links within the virtual file system. Symlinks are +created using `vfs.symlinkSync()` or `vfs.promises.symlink()` and can point +to files or directories within the same VFS. + +### Cross-boundary symlinks + +Symbolic links in the VFS are **VFS-internal only**. They cannot: + +* Point from a VFS path to a real file system path +* Point from a real file system path to a VFS path +* Be followed across VFS mount boundaries + +When resolving symlinks, the VFS only follows links that target paths within +the same VFS instance. Attempts to create symlinks with absolute paths that +would resolve outside the VFS are allowed but will result in dangling symlinks. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.mkdirSync('/data'); +myVfs.writeFileSync('/data/config.json', JSON.stringify({})); + +// This works - symlink within VFS +myVfs.symlinkSync('/data/config.json', '/config'); +myVfs.readFileSync('/config', 'utf8'); // '{}' + +// This creates a dangling symlink - target doesn't exist in VFS +myVfs.symlinkSync('/etc/passwd', '/passwd-link'); +// myVfs.readFileSync('/passwd-link'); // Throws ENOENT +``` + +### Symlinks in overlay mode + +In overlay mode (`{ overlay: true }`), VFS and real file system symlinks remain +completely independent: + +* **VFS symlinks** can only target other VFS paths. A VFS symlink cannot point + to a real file system file, even if that file exists at the same logical path. +* **Real file system symlinks** can only target other real file system paths. + A real symlink cannot point to a VFS file. +* **No cross-layer resolution** occurs. When following a symlink, the resolution + stays entirely within either the VFS layer or the real file system layer. + +```cjs +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +const myVfs = vfs.create({ overlay: true }); +myVfs.mkdirSync('/data'); +myVfs.writeFileSync('/data/config.json', JSON.stringify({ source: 'vfs' })); +myVfs.symlinkSync('/data/config.json', '/data/link'); +myVfs.mount('/app'); + +// VFS symlink resolves within VFS +fs.readFileSync('/app/data/link', 'utf8'); // '{"source": "vfs"}' + +// If /app/data/real-link is a real FS symlink pointing to /app/data/config.json, +// it will NOT resolve to the VFS file - it looks for a real file at that path +``` + +This design ensures predictable behavior: symlinks always resolve within their +own layer, preventing unexpected interactions between virtual and real files. + +## Worker threads + +VFS instances are **not shared across worker threads**. Each worker thread has +its own V8 isolate and module cache, which means: + +* A VFS mounted in the main thread is not accessible from worker threads +* Each worker thread must create and mount its own VFS instance +* VFS data is not synchronized between threads - changes in one thread are not + visible in another + +If you need to share virtual file content with worker threads, you must either: + +1. **Recreate the VFS in each worker** - Pass the data to workers via + `workerData` and have each worker create its own VFS: + +```cjs +const { Worker, isMainThread, workerData } = require('node:worker_threads'); +const vfs = require('node:vfs'); + +if (isMainThread) { + const fileData = { '/config.json': '{"key": "value"}' }; + new Worker(__filename, { workerData: fileData }); +} else { + // Worker: recreate VFS from passed data + const myVfs = vfs.create(); + for (const [path, content] of Object.entries(workerData)) { + myVfs.writeFileSync(path, content); + } + myVfs.mount('/virtual'); + // Now the worker has its own copy of the VFS +} +``` + +2. **Use `RealFSProvider`** - If the data exists on the real file system, use + `RealFSProvider` in each worker to mount the same directory. + +### Using `virtualCwd` in Worker threads + +Since `process.chdir()` is not available in Worker threads, you can use +`RealFSProvider` to enable virtual working directory support: + +```cjs +const { Worker, isMainThread, parentPort } = require('node:worker_threads'); +const vfs = require('node:vfs'); + +if (isMainThread) { + new Worker(__filename); +} else { + // In worker: mount real file system with virtualCwd enabled + const realVfs = vfs.create( + new vfs.RealFSProvider('/home/user/project'), + { virtualCwd: true }, + ); + realVfs.mount('/project'); + + // Now we can use virtual chdir in the worker + realVfs.chdir('/project/src'); + console.log(realVfs.cwd()); // '/project/src' +} +``` + +This limitation exists because implementing cross-thread VFS access would +require moving the implementation to C++ with shared memory management, which +significantly increases complexity. This may be addressed in future versions. + +## Security considerations + +### Path shadowing + +When a VFS is mounted, it **shadows** any real file system paths under the +mount prefix. This means: + +* Real files at the mount path become inaccessible +* All operations are redirected to the VFS +* Modules loaded from shadowed paths will use VFS content + +This behavior can be exploited maliciously. A module could mount a VFS over +critical system paths (like `/etc` on Unix or `C:\Windows` on Windows) and +intercept sensitive operations: + +```cjs +// WARNING: Example of dangerous behavior - DO NOT DO THIS +const vfs = require('node:vfs'); + +const maliciousVfs = vfs.create(); +maliciousVfs.writeFileSync('/passwd', 'malicious content'); +maliciousVfs.mount('/etc'); // Shadows /etc/passwd! + +// Now fs.readFileSync('/etc/passwd') returns 'malicious content' +``` + +### Overlay mode risks + +Overlay mode (`{ overlay: true }`) allows a VFS to selectively intercept file +operations only for paths that exist in the VFS. While this is useful for +mocking specific files in tests, it can also be exploited to covertly intercept +access to specific files: + +```cjs +// WARNING: Example of dangerous behavior - DO NOT DO THIS +const vfs = require('node:vfs'); + +// Create an overlay VFS that intercepts a specific file +const spyVfs = vfs.create(new vfs.MemoryProvider(), { overlay: true }); +spyVfs.writeFileSync('/etc/shadow', 'intercepted!'); +spyVfs.mount('/'); // Mount at root with overlay mode + +// Only /etc/shadow is intercepted, other files work normally +fs.readFileSync('/etc/passwd'); // Real file (works normally) +fs.readFileSync('/etc/shadow'); // Returns 'intercepted!' (mocked) +``` + +This is particularly dangerous because: + +* It is harder to detect than full path shadowing. +* Only specific targeted files are affected. +* Other operations appear to work normally. + +### Recommendations + +* **Audit dependencies**: Be cautious of third-party modules that use VFS, as + they could shadow important paths. +* **Use unique mount points**: Mount VFS at paths that don't conflict with + real file system paths, such as `/@virtual` or `/vfs-{unique-id}`. +* **Verify mount points**: Before trusting file content from paths that could + be shadowed, verify the mount state. +* **Limit VFS usage**: Only use VFS in controlled environments where you trust + all loaded modules. + +[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management +[Security considerations]: #security-considerations +[Single Executable Applications]: single-executable-applications.md diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index 0415763e360246..c8971514e52a0f 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -130,6 +130,7 @@ 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']); diff --git a/lib/internal/vfs/dir.js b/lib/internal/vfs/dir.js new file mode 100644 index 00000000000000..90458631ccec49 --- /dev/null +++ b/lib/internal/vfs/dir.js @@ -0,0 +1,109 @@ +'use strict'; + +const { + SymbolAsyncIterator, + SymbolAsyncDispose, + 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..98b83bd4dbef0e --- /dev/null +++ b/lib/internal/vfs/errors.js @@ -0,0 +1,211 @@ +'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, + UV_EXDEV, +} = 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; +} + +/** + * Creates an EXDEV error for cross-device link operations. + * @param {string} syscall The system call name + * @param {string} path The path + * @returns {Error} + */ +function createEXDEV(syscall, path) { + const err = new UVException({ + errno: UV_EXDEV, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createEXDEV); + return err; +} + +module.exports = { + createENOENT, + createENOTDIR, + createENOTEMPTY, + createEISDIR, + createEBADF, + createEEXIST, + createEROFS, + createEINVAL, + createELOOP, + createEACCES, + createEXDEV, +}; 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..f82471e182b46a --- /dev/null +++ b/lib/internal/vfs/file_handle.js @@ -0,0 +1,732 @@ +'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); + } + + /** + * @returns {Promise} + */ + readableWebStream() { + throw new ERR_METHOD_NOT_IMPLEMENTED('readableWebStream'); + } + + /** + * @returns {AsyncIterable} + */ + readLines() { + throw new ERR_METHOD_NOT_IMPLEMENTED('readLines'); + } + + /** + * @returns {ReadStream} + */ + createReadStream() { + throw new ERR_METHOD_NOT_IMPLEMENTED('createReadStream'); + } + + /** + * @returns {WriteStream} + */ + 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..01486e1fbda071 --- /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] Whether to emit the public VFS 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..a3e29fea200ebf --- /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..cf484ab41b1f4d --- /dev/null +++ b/lib/internal/vfs/streams.js @@ -0,0 +1,357 @@ +'use strict'; + +const { + MathMin, +} = primordials; + +const { Buffer } = require('buffer'); +const { Readable, Writable } = require('stream'); +const { createEBADF } = require('internal/vfs/errors'); +const { getLazy, kEmptyObject } = require('internal/util'); +const { validateInteger } = require('internal/validators'); +const { + codes: { ERR_OUT_OF_RANGE }, +} = require('internal/errors'); + +// Lazy-load fd module to avoid circular dependency +const lazyGetVirtualFd = getLazy( + () => require('internal/vfs/fd').getVirtualFd, +); + +/** + * 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 getVirtualFd = lazyGetVirtualFd(); + 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 getVirtualFd = lazyGetVirtualFd(); + 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..d21f292c630ab3 --- /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/test/parallel/test-vfs-append-write.js b/test/parallel/test-vfs-append-write.js new file mode 100644 index 00000000000000..d7c480851b209c --- /dev/null +++ b/test/parallel/test-vfs-append-write.js @@ -0,0 +1,18 @@ +'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-ctime-update.js b/test/parallel/test-vfs-ctime-update.js new file mode 100644 index 00000000000000..75a4a46762ccfd --- /dev/null +++ b/test/parallel/test-vfs-ctime-update.js @@ -0,0 +1,48 @@ +'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-fd.js b/test/parallel/test-vfs-fd.js new file mode 100644 index 00000000000000..f2b56be8783bfa --- /dev/null +++ b/test/parallel/test-vfs-fd.js @@ -0,0 +1,318 @@ +'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-promises.js b/test/parallel/test-vfs-promises.js new file mode 100644 index 00000000000000..86347228bc5c06 --- /dev/null +++ b/test/parallel/test-vfs-promises.js @@ -0,0 +1,491 @@ +// Flags: --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..31f44b8ebd1e2d --- /dev/null +++ b/test/parallel/test-vfs-readdir-symlink-recursive.js @@ -0,0 +1,21 @@ +'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}`, +); diff --git a/test/parallel/test-vfs-readfile-flag.js b/test/parallel/test-vfs-readfile-flag.js new file mode 100644 index 00000000000000..2291a5714916a1 --- /dev/null +++ b/test/parallel/test-vfs-readfile-flag.js @@ -0,0 +1,38 @@ +'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-stats-ino-dev.js b/test/parallel/test-vfs-stats-ino-dev.js new file mode 100644 index 00000000000000..1aba60a23829b3 --- /dev/null +++ b/test/parallel/test-vfs-stats-ino-dev.js @@ -0,0 +1,23 @@ +'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-properties.js b/test/parallel/test-vfs-stream-properties.js new file mode 100644 index 00000000000000..5deea9be1bc2fe --- /dev/null +++ b/test/parallel/test-vfs-stream-properties.js @@ -0,0 +1,39 @@ +'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-streams.js b/test/parallel/test-vfs-streams.js new file mode 100644 index 00000000000000..5a3361fc359091 --- /dev/null +++ b/test/parallel/test-vfs-streams.js @@ -0,0 +1,212 @@ +'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(); + }); + })); +} diff --git a/test/parallel/test-vfs-watch-directory.js b/test/parallel/test-vfs-watch-directory.js new file mode 100644 index 00000000000000..a13563bac2ab6c --- /dev/null +++ b/test/parallel/test-vfs-watch-directory.js @@ -0,0 +1,50 @@ +'use strict'; + +// Tests for VFS directory watching: +// - watch() on directories reports child changes +// - Recursive watchers discover descendants created after startup + +const common = require('../common'); +const assert = require('assert'); +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); +} + +// Files created after a recursive watcher starts must still trigger events. +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/parent', { recursive: true }); + + let gotEvent = false; + const watcher = myVfs.watch('/parent', { + recursive: true, + interval: 50, + persistent: false, + }); + watcher.on('change', common.mustCallAtLeast((eventType, filename) => { + if (filename === 'new.txt') { + gotEvent = true; + } + })); + + setTimeout(() => myVfs.writeFileSync('/parent/new.txt', 'first'), 70); + setTimeout(common.mustCall(() => { + watcher.close(); + assert.strictEqual(gotEvent, true); + }), 300); +} diff --git a/test/parallel/test-vfs-watchfile.js b/test/parallel/test-vfs-watchfile.js new file mode 100644 index 00000000000000..a3a0bc17643abd --- /dev/null +++ b/test/parallel/test-vfs-watchfile.js @@ -0,0 +1,35 @@ +'use strict'; + +// Tests for VFS watchFile/unwatchFile: +// - unwatchFile(path) without a specific listener cleans up properly +// - watchFile() zero stats for missing file use all-zero mode + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// unwatchFile(path) without a specific listener must clean up the timer. +// If the fix is wrong, the process would hang due to a leaked timer. +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/a.txt', 'x'); + + myVfs.watchFile('/a.txt', { interval: 50, persistent: false }, () => {}); + myVfs.unwatchFile('/a.txt'); +} + +// watchFile() zero stats for a missing file must have all-zero mode. +// The previous-stats argument for a newly-created file should report +// isFile() === false and mode === 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); +} From fff45aa0e28cd79deb61e6788e8e86b54eef9ea0 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 30 Apr 2026 16:01:40 +0200 Subject: [PATCH 02/14] test: add VFS API tests adapted from mount-based tests Adapts tests that exercised behavior through fs integration so they call the VFS API directly instead. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- .../test-vfs-mkdir-recursive-return.js | 17 + test/parallel/test-vfs-promises-open.js | 16 + test/parallel/test-vfs-readfile-async.js | 25 + test/parallel/test-vfs-readfile-encoding.js | 21 + test/parallel/test-vfs-real-provider.js | 504 ++++++++++++++++++ test/parallel/test-vfs-stats-bigint.js | 16 + test/parallel/test-vfs-stream-validation.js | 28 + test/parallel/test-vfs-truncate-negative.js | 14 + 8 files changed, 641 insertions(+) create mode 100644 test/parallel/test-vfs-mkdir-recursive-return.js create mode 100644 test/parallel/test-vfs-promises-open.js create mode 100644 test/parallel/test-vfs-readfile-async.js create mode 100644 test/parallel/test-vfs-readfile-encoding.js create mode 100644 test/parallel/test-vfs-real-provider.js create mode 100644 test/parallel/test-vfs-stats-bigint.js create mode 100644 test/parallel/test-vfs-stream-validation.js create mode 100644 test/parallel/test-vfs-truncate-negative.js diff --git a/test/parallel/test-vfs-mkdir-recursive-return.js b/test/parallel/test-vfs-mkdir-recursive-return.js new file mode 100644 index 00000000000000..e3bd6b7c5e9309 --- /dev/null +++ b/test/parallel/test-vfs-mkdir-recursive-return.js @@ -0,0 +1,17 @@ +'use strict'; + +// Verify mkdirSync({ recursive: true }) returns the first directory created. +// When some parent directories already exist, the return value should be the +// first newly-created directory path. + +require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/a'); + + const result = myVfs.mkdirSync('/a/b/c', { recursive: true }); + assert.strictEqual(result, '/a/b'); +} diff --git a/test/parallel/test-vfs-promises-open.js b/test/parallel/test-vfs-promises-open.js new file mode 100644 index 00000000000000..445f6f9cb434b0 --- /dev/null +++ b/test/parallel/test-vfs-promises-open.js @@ -0,0 +1,16 @@ +'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-readfile-async.js b/test/parallel/test-vfs-readfile-async.js new file mode 100644 index 00000000000000..733ed8701e8759 --- /dev/null +++ b/test/parallel/test-vfs-readfile-async.js @@ -0,0 +1,25 @@ +'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.mustCall((err, data) => { + assert.ifError(err); + assert.strictEqual(data, 'async content'); +})); + +myVfs.readFile('/async-read.txt', common.mustCall((err, data) => { + assert.ifError(err); + 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..323b162f8c549a --- /dev/null +++ b/test/parallel/test-vfs-readfile-encoding.js @@ -0,0 +1,21 @@ +'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-real-provider.js b/test/parallel/test-vfs-real-provider.js new file mode 100644 index 00000000000000..71e3b729ef34ca --- /dev/null +++ b/test/parallel/test-vfs-real-provider.js @@ -0,0 +1,504 @@ +// Flags: --expose-internals +'use strict'; + +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 testDir = path.join(tmpdir.path, 'vfs-real-provider'); +fs.mkdirSync(testDir, { recursive: true }); + +// Test basic RealFSProvider creation +{ + const provider = new vfs.RealFSProvider(testDir); + assert.ok(provider); + assert.strictEqual(provider.rootPath, testDir); + assert.strictEqual(provider.readonly, false); + assert.strictEqual(provider.supportsSymlinks, true); +} + +// Test invalid rootPath +{ + assert.throws(() => { + new vfs.RealFSProvider(''); + }, { code: 'ERR_INVALID_ARG_VALUE' }); + + assert.throws(() => { + new vfs.RealFSProvider(123); + }, { code: 'ERR_INVALID_ARG_VALUE' }); +} + +// Test creating VFS with RealFSProvider +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + assert.ok(realVfs); + assert.strictEqual(realVfs.readonly, false); +} + +// Test reading and writing files through RealFSProvider +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + // Write a file through VFS + realVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); + + // Verify it exists on the real file system + const realPath = path.join(testDir, 'hello.txt'); + assert.strictEqual(fs.existsSync(realPath), true); + assert.strictEqual(fs.readFileSync(realPath, 'utf8'), 'Hello from VFS!'); + + // Read it back through VFS + assert.strictEqual(realVfs.readFileSync('/hello.txt', 'utf8'), 'Hello from VFS!'); + + // Clean up + fs.unlinkSync(realPath); +} + +// Test stat operations +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + // Create a file and directory + fs.writeFileSync(path.join(testDir, 'stat-test.txt'), 'content'); + fs.mkdirSync(path.join(testDir, 'stat-dir'), { recursive: true }); + + const fileStat = realVfs.statSync('/stat-test.txt'); + assert.strictEqual(fileStat.isFile(), true); + assert.strictEqual(fileStat.isDirectory(), false); + + const dirStat = realVfs.statSync('/stat-dir'); + assert.strictEqual(dirStat.isFile(), false); + assert.strictEqual(dirStat.isDirectory(), true); + + // Test ENOENT + assert.throws(() => { + realVfs.statSync('/nonexistent'); + }, { code: 'ENOENT' }); + + // Clean up + fs.unlinkSync(path.join(testDir, 'stat-test.txt')); + fs.rmdirSync(path.join(testDir, 'stat-dir')); +} + +// Test 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']); + + // With file types + const dirents = realVfs.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); + + // Clean up + 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')); +} + +// Test mkdir and rmdir +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + realVfs.mkdirSync('/new-dir'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'new-dir')), true); + assert.strictEqual(fs.statSync(path.join(testDir, 'new-dir')).isDirectory(), true); + + realVfs.rmdirSync('/new-dir'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'new-dir')), false); +} + +// Test 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); +} + +// Test 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.existsSync(path.join(testDir, 'new-name.txt')), true); + assert.strictEqual(fs.readFileSync(path.join(testDir, 'new-name.txt'), 'utf8'), 'rename me'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'new-name.txt')); +} + +// Test path traversal prevention +{ + const subDir = path.join(testDir, 'sandbox'); + fs.mkdirSync(subDir, { recursive: true }); + + const realVfs = vfs.create(new vfs.RealFSProvider(subDir)); + + // Trying to access parent via .. should fail + assert.throws(() => { + realVfs.statSync('/../hello.txt'); + }, { code: 'ENOENT' }); + + assert.throws(() => { + realVfs.readFileSync('/../../../etc/passwd'); + }, { code: 'ENOENT' }); + + // Clean up + fs.rmdirSync(subDir); +} + +// Test async operations +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + await realVfs.promises.writeFile('/async-test.txt', 'async content'); + const content = await realVfs.promises.readFile('/async-test.txt', 'utf8'); + assert.strictEqual(content, 'async content'); + + const stat = await realVfs.promises.stat('/async-test.txt'); + assert.strictEqual(stat.isFile(), true); + + await realVfs.promises.unlink('/async-test.txt'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'async-test.txt')), false); +})().then(common.mustCall()); + +// Test 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.existsSync(path.join(testDir, 'dest.txt')), true); + assert.strictEqual(fs.readFileSync(path.join(testDir, 'dest.txt'), 'utf8'), 'copy me'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'source.txt')); + fs.unlinkSync(path.join(testDir, 'dest.txt')); +} + +// Test realpathSync +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'real.txt'), 'content'); + + const resolved = realVfs.realpathSync('/real.txt'); + assert.strictEqual(resolved, '/real.txt'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'real.txt')); +} + +// Test file handle operations via openSync +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'handle-test.txt'), 'hello world'); + + const fd = realVfs.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'); + + realVfs.closeSync(fd); + + // Clean up + fs.unlinkSync(path.join(testDir, 'handle-test.txt')); +} + +// Test file handle write operations +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + const fd = realVfs.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); + + realVfs.closeSync(fd); + + // Verify content + assert.strictEqual(fs.readFileSync(path.join(testDir, 'write-handle.txt'), 'utf8'), 'written via handle'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'write-handle.txt')); +} + +// Test async file handle read +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'async-handle.txt'), 'async read test'); + + const fd = realVfs.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'); + + realVfs.closeSync(fd); + + // Clean up + fs.unlinkSync(path.join(testDir, 'async-handle.txt')); +})().then(common.mustCall()); + +// Test async file handle write +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + const fd = realVfs.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); + + realVfs.closeSync(fd); + + // Verify content + assert.strictEqual(fs.readFileSync(path.join(testDir, 'async-write.txt'), 'utf8'), 'async write'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'async-write.txt')); +})().then(common.mustCall()); + +// Test async file handle stat +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'stat-handle.txt'), 'stat test'); + + const fd = realVfs.openSync('/stat-handle.txt', 'r'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + const stat = await handle.entry.stat(); + assert.strictEqual(stat.isFile(), true); + + realVfs.closeSync(fd); + + // Clean up + fs.unlinkSync(path.join(testDir, 'stat-handle.txt')); +})().then(common.mustCall()); + +// Test async file handle truncate +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'truncate-handle.txt'), 'truncate this'); + + const fd = realVfs.openSync('/truncate-handle.txt', 'r+'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + await handle.entry.truncate(8); + realVfs.closeSync(fd); + + // Verify content was truncated + assert.strictEqual(fs.readFileSync(path.join(testDir, 'truncate-handle.txt'), 'utf8'), 'truncate'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'truncate-handle.txt')); +})().then(common.mustCall()); + +// Test async file handle close +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'close-handle.txt'), 'close test'); + + const fd = realVfs.openSync('/close-handle.txt', 'r'); + const handle = require('internal/vfs/fd').getVirtualFd(fd); + + await handle.entry.close(); + assert.strictEqual(handle.entry.closed, true); + + // Clean up + fs.unlinkSync(path.join(testDir, 'close-handle.txt')); +})().then(common.mustCall()); + +// Test recursive mkdir +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + realVfs.mkdirSync('/deep/nested/dir', { recursive: true }); + assert.strictEqual(fs.existsSync(path.join(testDir, 'deep/nested/dir')), true); + + // Clean up + fs.rmdirSync(path.join(testDir, 'deep/nested/dir')); + fs.rmdirSync(path.join(testDir, 'deep/nested')); + fs.rmdirSync(path.join(testDir, 'deep')); +} + +// Test lstatSync +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'lstat.txt'), 'lstat test'); + + const stat = realVfs.lstatSync('/lstat.txt'); + assert.strictEqual(stat.isFile(), true); + + // Clean up + fs.unlinkSync(path.join(testDir, 'lstat.txt')); +} + +// Test async lstat +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'async-lstat.txt'), 'async lstat'); + + const stat = await realVfs.promises.lstat('/async-lstat.txt'); + assert.strictEqual(stat.isFile(), true); + + // Clean up + fs.unlinkSync(path.join(testDir, 'async-lstat.txt')); +})().then(common.mustCall()); + +// Test async copyFile +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'async-src.txt'), 'async copy'); + + await realVfs.promises.copyFile('/async-src.txt', '/async-dest.txt'); + + assert.strictEqual(fs.existsSync(path.join(testDir, 'async-dest.txt')), true); + assert.strictEqual(fs.readFileSync(path.join(testDir, 'async-dest.txt'), 'utf8'), 'async copy'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'async-src.txt')); + fs.unlinkSync(path.join(testDir, 'async-dest.txt')); +})().then(common.mustCall()); + +// Test async mkdir and rmdir +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + await realVfs.promises.mkdir('/async-dir'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'async-dir')), true); + + await realVfs.promises.rmdir('/async-dir'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'async-dir')), false); +})().then(common.mustCall()); + +// Test async rename +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'async-old.txt'), 'async rename'); + + await realVfs.promises.rename('/async-old.txt', '/async-new.txt'); + + assert.strictEqual(fs.existsSync(path.join(testDir, 'async-old.txt')), false); + assert.strictEqual(fs.existsSync(path.join(testDir, 'async-new.txt')), true); + + // Clean up + fs.unlinkSync(path.join(testDir, 'async-new.txt')); +})().then(common.mustCall()); + +// Test async readdir +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.mkdirSync(path.join(testDir, 'async-readdir'), { recursive: true }); + fs.writeFileSync(path.join(testDir, 'async-readdir', 'file.txt'), 'content'); + + const entries = await realVfs.promises.readdir('/async-readdir'); + assert.deepStrictEqual(entries, ['file.txt']); + + // Clean up + fs.unlinkSync(path.join(testDir, 'async-readdir', 'file.txt')); + fs.rmdirSync(path.join(testDir, 'async-readdir')); +})().then(common.mustCall()); + +// Test async unlink +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'async-unlink.txt'), 'to delete'); + + await realVfs.promises.unlink('/async-unlink.txt'); + assert.strictEqual(fs.existsSync(path.join(testDir, 'async-unlink.txt')), false); +})().then(common.mustCall()); + +// Test file handle readFile and writeFile +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'handle-rw.txt'), 'original'); + + const fd = realVfs.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'); + realVfs.closeSync(fd); + + assert.strictEqual(fs.readFileSync(path.join(testDir, 'handle-rw.txt'), 'utf8'), 'replaced'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'handle-rw.txt')); +})().then(common.mustCall()); + +// Test async readFile and writeFile on handle +(async () => { + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'async-rw.txt'), 'async original'); + + const fd = realVfs.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'); + realVfs.closeSync(fd); + + assert.strictEqual(fs.readFileSync(path.join(testDir, 'async-rw.txt'), 'utf8'), 'async replaced'); + + // Clean up + fs.unlinkSync(path.join(testDir, 'async-rw.txt')); +})().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..1c42eaa0bbf165 --- /dev/null +++ b/test/parallel/test-vfs-stats-bigint.js @@ -0,0 +1,16 @@ +'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'); diff --git a/test/parallel/test-vfs-stream-validation.js b/test/parallel/test-vfs-stream-validation.js new file mode 100644 index 00000000000000..57b1cd69309738 --- /dev/null +++ b/test/parallel/test-vfs-stream-validation.js @@ -0,0 +1,28 @@ +'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-truncate-negative.js b/test/parallel/test-vfs-truncate-negative.js new file mode 100644 index 00000000000000..2ce2c9e48ccc21 --- /dev/null +++ b/test/parallel/test-vfs-truncate-negative.js @@ -0,0 +1,14 @@ +'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, ''); From b8219f93373a93ec610d61f1fa28b10215f6839b Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 1 May 2026 10:10:47 +0200 Subject: [PATCH 03/14] test: add VFS unit tests for VirtualDir, file handles, and provider base Cover VirtualDir iteration and disposal, MemoryFileHandle read/write methods via the provider, and the VirtualProvider base class (capability flags, readonly stubs, default implementations). Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- test/parallel/test-vfs-dir-handle.js | 73 ++++++++++++++ test/parallel/test-vfs-file-handle.js | 123 ++++++++++++++++++++++++ test/parallel/test-vfs-provider-base.js | 107 +++++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 test/parallel/test-vfs-dir-handle.js create mode 100644 test/parallel/test-vfs-file-handle.js create mode 100644 test/parallel/test-vfs-provider-base.js diff --git a/test/parallel/test-vfs-dir-handle.js b/test/parallel/test-vfs-dir-handle.js new file mode 100644 index 00000000000000..e78753a9e389d5 --- /dev/null +++ b/test/parallel/test-vfs-dir-handle.js @@ -0,0 +1,73 @@ +'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) => err ? reject(err) : resolve(entry)); + }); + await new Promise((resolve, reject) => { + dir.close((err) => err ? reject(err) : 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.mustCall((err, dir) => { + assert.ifError(err); + assert.strictEqual(dir.path, '/d'); + dir.closeSync(); +})); diff --git a/test/parallel/test-vfs-file-handle.js b/test/parallel/test-vfs-file-handle.js new file mode 100644 index 00000000000000..307a909cead4ad --- /dev/null +++ b/test/parallel/test-vfs-file-handle.js @@ -0,0 +1,123 @@ +'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()); diff --git a/test/parallel/test-vfs-provider-base.js b/test/parallel/test-vfs-provider-base.js new file mode 100644 index 00000000000000..5a8b01ed02cd2d --- /dev/null +++ b/test/parallel/test-vfs-provider-base.js @@ -0,0 +1,107 @@ +'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); +} From d01bfc1477a72855eaf4e8f3d71562ce182a9381 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 1 May 2026 12:24:09 +0200 Subject: [PATCH 04/14] test: adapt more VFS tests to direct API Covers MemoryProvider, copyFile mode, rm edge cases, hardlinks, bigint read positions, and parent timestamps via the VFS API. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- test/parallel/test-vfs-bigint-position.js | 17 + test/parallel/test-vfs-copyfile-mode.js | 51 ++ test/parallel/test-vfs-hardlink-nlink.js | 31 + test/parallel/test-vfs-parent-timestamps.js | 24 + test/parallel/test-vfs-provider-memory.js | 664 ++++++++++++++++++++ test/parallel/test-vfs-rm-edge-cases.js | 69 ++ test/parallel/test-vfs-rmdir-symlink.js | 30 + 7 files changed, 886 insertions(+) create mode 100644 test/parallel/test-vfs-bigint-position.js create mode 100644 test/parallel/test-vfs-copyfile-mode.js create mode 100644 test/parallel/test-vfs-hardlink-nlink.js create mode 100644 test/parallel/test-vfs-parent-timestamps.js create mode 100644 test/parallel/test-vfs-provider-memory.js create mode 100644 test/parallel/test-vfs-rm-edge-cases.js create mode 100644 test/parallel/test-vfs-rmdir-symlink.js diff --git a/test/parallel/test-vfs-bigint-position.js b/test/parallel/test-vfs-bigint-position.js new file mode 100644 index 00000000000000..e2963021414b75 --- /dev/null +++ b/test/parallel/test-vfs-bigint-position.js @@ -0,0 +1,17 @@ +'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-copyfile-mode.js b/test/parallel/test-vfs-copyfile-mode.js new file mode 100644 index 00000000000000..ae7e3048e8bdaf --- /dev/null +++ b/test/parallel/test-vfs-copyfile-mode.js @@ -0,0 +1,51 @@ +'use strict'; + +// Tests for VFS copyFile mode support: +// - COPYFILE_EXCL throws when destination exists +// - Without COPYFILE_EXCL, copy overwrites destination + +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'); +})(); diff --git a/test/parallel/test-vfs-hardlink-nlink.js b/test/parallel/test-vfs-hardlink-nlink.js new file mode 100644 index 00000000000000..b1baaee236691f --- /dev/null +++ b/test/parallel/test-vfs-hardlink-nlink.js @@ -0,0 +1,31 @@ +'use strict'; + +// Test that nlink count is updated correctly when creating/removing hard links. + +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); +})(); diff --git a/test/parallel/test-vfs-parent-timestamps.js b/test/parallel/test-vfs-parent-timestamps.js new file mode 100644 index 00000000000000..de7ce1cefbf734 --- /dev/null +++ b/test/parallel/test-vfs-parent-timestamps.js @@ -0,0 +1,24 @@ +'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-provider-memory.js b/test/parallel/test-vfs-provider-memory.js new file mode 100644 index 00000000000000..d4fb7c3b2843d2 --- /dev/null +++ b/test/parallel/test-vfs-provider-memory.js @@ -0,0 +1,664 @@ +// Flags: --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-rm-edge-cases.js b/test/parallel/test-vfs-rm-edge-cases.js new file mode 100644 index 00000000000000..23e0934ed7c933 --- /dev/null +++ b/test/parallel/test-vfs-rm-edge-cases.js @@ -0,0 +1,69 @@ +'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 + +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); +})(); diff --git a/test/parallel/test-vfs-rmdir-symlink.js b/test/parallel/test-vfs-rmdir-symlink.js new file mode 100644 index 00000000000000..5ab1bfd7a246c3 --- /dev/null +++ b/test/parallel/test-vfs-rmdir-symlink.js @@ -0,0 +1,30 @@ +'use strict'; + +// rmdirSync on a symlink to a directory should throw ENOTDIR + +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' }); +})(); From 0a0931e636f18a673663e1afb1497f2c19416d1a Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 1 May 2026 13:59:47 +0200 Subject: [PATCH 05/14] test: add VFS callback, stream, watch, and real-provider async tests Cover the callback-style async API, additional read/write stream flows, the promises.watch async iterable, and async methods of RealFSProvider. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- test/parallel/test-vfs-callbacks.js | 182 ++++++++++++++++++ test/parallel/test-vfs-real-provider-async.js | 123 ++++++++++++ test/parallel/test-vfs-streams-misc.js | 116 +++++++++++ test/parallel/test-vfs-watch-async.js | 93 +++++++++ 4 files changed, 514 insertions(+) create mode 100644 test/parallel/test-vfs-callbacks.js create mode 100644 test/parallel/test-vfs-real-provider-async.js create mode 100644 test/parallel/test-vfs-streams-misc.js create mode 100644 test/parallel/test-vfs-watch-async.js diff --git a/test/parallel/test-vfs-callbacks.js b/test/parallel/test-vfs-callbacks.js new file mode 100644 index 00000000000000..b949886835686b --- /dev/null +++ b/test/parallel/test-vfs-callbacks.js @@ -0,0 +1,182 @@ +'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.mustCall((err, data) => { + assert.ifError(err); + assert.ok(Buffer.isBuffer(data)); +})); + +// writeFile + appendFile (no options) -> readFile +myVfs.writeFile('/cb-write.txt', 'a', common.mustCall((err) => { + assert.ifError(err); + myVfs.readFile('/cb-write.txt', 'utf8', common.mustCall((err2, data) => { + assert.ifError(err2); + assert.strictEqual(data, 'a'); + })); +})); + +// stat / lstat (with and without options) +myVfs.stat('/dir/file.txt', common.mustCall((err, st) => { + assert.ifError(err); + assert.strictEqual(st.size, 5); +})); +myVfs.stat('/dir/file.txt', { bigint: true }, common.mustCall((err, st) => { + assert.ifError(err); + assert.strictEqual(typeof st.size, 'bigint'); +})); +myVfs.lstat('/dir/file.txt', common.mustCall((err, st) => { + assert.ifError(err); + assert.ok(st.isFile()); +})); + +// readdir +myVfs.readdir('/dir', common.mustCall((err, names) => { + assert.ifError(err); + assert.ok(names.includes('file.txt')); +})); + +// realpath +myVfs.realpath('/dir/file.txt', common.mustCall((err, p) => { + assert.ifError(err); + assert.strictEqual(p, '/dir/file.txt'); +})); + +// access (with and without mode) +myVfs.access('/dir/file.txt', common.mustCall((err) => { + assert.ifError(err); +})); +myVfs.access('/dir/file.txt', 0, common.mustCall((err) => { + assert.ifError(err); +})); +myVfs.access('/missing.txt', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); +})); + +// open / read / write / close cb chain +myVfs.open('/dir/file.txt', common.mustCall((err, fd) => { + assert.ifError(err); + const buf = Buffer.alloc(5); + myVfs.read(fd, buf, 0, 5, 0, common.mustCall((err2, bytesRead) => { + assert.ifError(err2); + assert.strictEqual(bytesRead, 5); + assert.strictEqual(buf.toString(), 'hello'); + myVfs.close(fd, common.mustCall((err3) => assert.ifError(err3))); + })); +})); + +// open with explicit flags / mode +myVfs.open('/dir/new1.txt', 'w', common.mustCall((err, fd) => { + assert.ifError(err); + const buf = Buffer.from('xyz'); + myVfs.write(fd, buf, 0, 3, 0, common.mustCall((err2, bytesWritten) => { + assert.ifError(err2); + assert.strictEqual(bytesWritten, 3); + myVfs.fstat(fd, common.mustCall((err3, st) => { + assert.ifError(err3); + assert.strictEqual(st.size, 3); + myVfs.ftruncate(fd, 1, common.mustCall((err5) => { + assert.ifError(err5); + myVfs.close(fd, common.mustCall()); + })); + })); + })); +})); + +// open with explicit flags, no mode arg form +myVfs.open('/dir/new2.txt', 'w', 0o644, common.mustCall((err, fd) => { + assert.ifError(err); + myVfs.close(fd, common.mustCall()); +})); + +// rm callback (file) +myVfs.writeFileSync('/cb-rm.txt', 'x'); +myVfs.rm('/cb-rm.txt', common.mustCall((err) => { + assert.ifError(err); + 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.mustCall((err) => { + assert.ifError(err); +})); + +// 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.mustCall((err) => { + assert.ifError(err); + 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.mustCall((err) => { + assert.ifError(err); +})); +myVfs.link('/missing-src.txt', '/cb-bad-link.txt', common.mustCall((err) => { + assert.ok(err); +})); + +// mkdtemp cb +myVfs.mkdtemp('/tmp-', common.mustCall((err, p) => { + assert.ifError(err); + assert.ok(p.startsWith('/tmp-')); +})); +myVfs.mkdtemp('/tmp-', {}, common.mustCall((err, p) => { + assert.ifError(err); + assert.ok(p.startsWith('/tmp-')); +})); + +// opendir cb +myVfs.opendir('/dir', common.mustCall((err, dir) => { + assert.ifError(err); + 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.mustCall((err, target) => { + assert.ifError(err); + assert.strictEqual(target, '/dir/file.txt'); +})); diff --git a/test/parallel/test-vfs-real-provider-async.js b/test/parallel/test-vfs-real-provider-async.js new file mode 100644 index 00000000000000..767a8a1fc777b3 --- /dev/null +++ b/test/parallel/test-vfs-real-provider-async.js @@ -0,0 +1,123 @@ +'use strict'; + +// Cover RealFSProvider async methods, symlinks, watch, and edge cases. + +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-async'); +fs.mkdirSync(root, { recursive: true }); + +const myVfs = vfs.create(new vfs.RealFSProvider(root)); + +(async () => { + // writeFile + readFile (async) + await myVfs.promises.writeFile('/a.txt', 'hello'); + assert.strictEqual(await myVfs.promises.readFile('/a.txt', 'utf8'), 'hello'); + + // stat / lstat / access async + const st = await myVfs.promises.stat('/a.txt'); + assert.strictEqual(st.size, 5); + await myVfs.promises.access('/a.txt'); + + // mkdir / readdir / rmdir async + 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 async + 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 async + await myVfs.promises.unlink('/new.txt'); + assert.strictEqual(myVfs.existsSync('/new.txt'), false); + + // copyFile async + await myVfs.promises.copyFile('/a.txt', '/copy.txt'); + assert.strictEqual(await myVfs.promises.readFile('/copy.txt', 'utf8'), 'hello'); + + // realpath / readlink async (with relative target staying in root) + await myVfs.promises.symlink('a.txt', '/link'); + assert.strictEqual(await myVfs.promises.readlink('/link'), 'a.txt'); + assert.strictEqual(await myVfs.promises.realpath('/link'), '/a.txt'); + // realpath on root + assert.strictEqual(myVfs.realpathSync('/'), '/'); +})().then(common.mustCall()); + +// Symlinks: absolute target rejected with EACCES +{ + assert.throws( + () => myVfs.symlinkSync('/etc/passwd', '/escape'), + { code: 'EACCES' }, + ); +} + +// promises.symlink with absolute target also rejected +(async () => { + await assert.rejects( + myVfs.promises.symlink('/etc/passwd', '/escape2'), + { code: 'EACCES' }, + ); +})().then(common.mustCall()); + +// readlinkSync on a symlink whose target is inside root → translated to VFS '/'-rooted path +{ + // First put a file at root + fs.writeFileSync(path.join(root, 'target.txt'), 'x'); + // Make a symlink whose absolute target is inside root via real fs + fs.symlinkSync(path.join(root, 'target.txt'), path.join(root, 'abs-link')); + const target = myVfs.readlinkSync('/abs-link'); + // Should translate to '/target.txt' (VFS-relative) + assert.strictEqual(target, '/target.txt'); +} + +// readlinkSync where target == root → '/' +{ + fs.symlinkSync(root, path.join(root, 'root-link')); + assert.strictEqual(myVfs.readlinkSync('/root-link'), '/'); + myVfs.promises.readlink('/root-link').then(common.mustCall( + (t) => assert.strictEqual(t, '/'), + )); +} + +// realpathSync on root subdir +{ + fs.mkdirSync(path.join(root, 'sub2'), { recursive: true }); + assert.strictEqual(myVfs.realpathSync('/sub2'), '/sub2'); + myVfs.promises.realpath('/sub2').then(common.mustCall( + (p) => assert.strictEqual(p, '/sub2'), + )); +} + +// Watch capability and method calls (real fs) +{ + assert.strictEqual(myVfs.provider.supportsWatch, true); + fs.writeFileSync(path.join(root, 'watch-me.txt'), 'a'); + + const watcher = myVfs.watch('/watch-me.txt', { persistent: false }); + watcher.close(); +} + +// promises.watch returns an async iterable (we just call .return() to close it) +(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-streams-misc.js b/test/parallel/test-vfs-streams-misc.js new file mode 100644 index 00000000000000..c45ddf34f26191 --- /dev/null +++ b/test/parallel/test-vfs-streams-misc.js @@ -0,0 +1,116 @@ +'use strict'; + +// Cover stream paths not exercised by other tests: +// - WriteStream basic write + close +// - createReadStream with start/end slicing +// - createReadStream with explicit fd +// - WriteStream with explicit fd +// - WriteStream with start position +// - error paths (open fails, EBADF on broken fd) + +const common = require('../common'); +const assert = require('assert'); +const { Readable } = require('stream'); +const { pipeline } = require('stream/promises'); +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 with start/end slicing +readStream(myVfs.createReadStream('/file.txt', { start: 6, end: 10 })) + .then(common.mustCall((s) => assert.strictEqual(s, 'world'))); + +// Read entire file +readStream(myVfs.createReadStream('/file.txt')) + .then(common.mustCall((s) => assert.strictEqual(s, 'hello world'))); + +// 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); + })); +} + +// Read of a nonexistent file emits 'error' (path not opened) — open is async +{ + const stream = myVfs.createReadStream('/missing.txt'); + stream.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); +} + +// Write basic +(async () => { + 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()); + +// Write with start position writes from there onward +(async () => { + myVfs.writeFileSync('/pad.txt', 'AAAAAAAAAA'); + await pipeline( + Readable.from([Buffer.from('XX')]), + myVfs.createWriteStream('/pad.txt', { start: 3, flags: 'r+' }), + ); + const got = myVfs.readFileSync('/pad.txt', 'utf8'); + assert.strictEqual(got, 'AAAXXAAAAA'); +})().then(common.mustCall()); + +// Write with string chunk + encoding +(async () => { + 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()); + +// Write 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 invalid path (no parent directory) emits error +{ + const stream = myVfs.createWriteStream('/non-existent-dir/file.txt'); + stream.on('error', common.mustCall((err) => { + assert.ok(err); + })); +} + +// path getter +{ + const rs = myVfs.createReadStream('/file.txt'); + assert.strictEqual(rs.path, '/file.txt'); + rs.destroy(); + + const ws = myVfs.createWriteStream('/p.txt'); + assert.strictEqual(ws.path, '/p.txt'); + ws.destroy(); +} diff --git a/test/parallel/test-vfs-watch-async.js b/test/parallel/test-vfs-watch-async.js new file mode 100644 index 00000000000000..e1363e0d5ff716 --- /dev/null +++ b/test/parallel/test-vfs-watch-async.js @@ -0,0 +1,93 @@ +'use strict'; + +// Cover VFSWatchAsyncIterable: promise-based watch(). + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'a'); + +// Basic async iter — receive at least one change event +(async () => { + const iter = myVfs.promises.watch('/file.txt', { interval: 25 }); + setTimeout(() => myVfs.writeFileSync('/file.txt', 'changed'), 60); + for await (const evt of iter) { + assert.strictEqual(evt.eventType, 'change'); + break; // closes via return() + } +})().then(common.mustCall()); + +// Pre-aborted signal -> resolves immediately as done +(async () => { + const ac = new AbortController(); + ac.abort(); + const iter = myVfs.promises.watch('/file.txt', { signal: ac.signal }); + const r = await iter.next(); + assert.strictEqual(r.done, true); +})().then(common.mustCall()); + +// Abort mid-flight -> rejects pending next() with AbortError +(async () => { + const ac = new AbortController(); + const iter = myVfs.promises.watch('/file.txt', { + signal: ac.signal, + interval: 1000, + }); + const pending = iter.next(); + setTimeout(() => ac.abort(), 20); + try { + await pending; + throw new Error('Expected rejection'); + } catch (err) { + assert.strictEqual(err.name, 'AbortError'); + } +})().then(common.mustCall()); + +// throw() on the iterator closes the watcher +(async () => { + const iter = myVfs.promises.watch('/file.txt', { interval: 1000 }); + const r = await iter.throw(new Error('go away')); + assert.strictEqual(r.done, true); +})().then(common.mustCall()); + +// Sync watch() also covers the basic flow +{ + const myVfs2 = vfs.create(); + myVfs2.writeFileSync('/file.txt', 'a'); + const watcher = myVfs2.watch('/file.txt', { interval: 25 }, + common.mustCallAtLeast(() => {}, 1)); + setTimeout(() => { + myVfs2.writeFileSync('/file.txt', 'b'); + setTimeout(() => watcher.close(), 100); + }, 30); +} + +// Recursive directory watch +{ + const myVfs3 = vfs.create(); + myVfs3.mkdirSync('/d/sub', { recursive: true }); + myVfs3.writeFileSync('/d/sub/file.txt', 'x'); + const watcher = myVfs3.watch('/d', { interval: 25, recursive: true }, + common.mustCallAtLeast(() => {}, 1)); + setTimeout(() => { + myVfs3.writeFileSync('/d/sub/file.txt', 'changed'); + setTimeout(() => watcher.close(), 100); + }, 30); +} + +// Buffer encoding +{ + const myVfs4 = vfs.create(); + myVfs4.writeFileSync('/file.txt', 'a'); + const watcher = myVfs4.watch('/file.txt', { interval: 25, encoding: 'buffer' }, + common.mustCallAtLeast((eventType, filename) => { + assert.strictEqual(eventType, 'change'); + assert.ok(Buffer.isBuffer(filename) || filename === null); + }, 1)); + setTimeout(() => { + myVfs4.writeFileSync('/file.txt', 'b'); + setTimeout(() => watcher.close(), 100); + }, 30); +} From c1481a498a69af0fd9825ec2a1a9449d9e5bb676 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 1 May 2026 14:57:05 +0200 Subject: [PATCH 06/14] test: cover remaining VFS API edge cases Removes the unused createEXDEV error helper, adds direct tests for MemoryProvider numeric flags / symlink loops / utimes variants, and adds a base-class VirtualFileHandle test. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- lib/internal/vfs/errors.js | 18 --- test/parallel/test-vfs-file-handle-base.js | 88 +++++++++++ test/parallel/test-vfs-memory-coverage.js | 168 +++++++++++++++++++++ test/parallel/test-vfs-misc-coverage.js | 131 ++++++++++++++++ 4 files changed, 387 insertions(+), 18 deletions(-) create mode 100644 test/parallel/test-vfs-file-handle-base.js create mode 100644 test/parallel/test-vfs-memory-coverage.js create mode 100644 test/parallel/test-vfs-misc-coverage.js diff --git a/lib/internal/vfs/errors.js b/lib/internal/vfs/errors.js index 98b83bd4dbef0e..79e4a647d133b1 100644 --- a/lib/internal/vfs/errors.js +++ b/lib/internal/vfs/errors.js @@ -19,7 +19,6 @@ const { UV_EINVAL, UV_ELOOP, UV_EACCES, - UV_EXDEV, } = internalBinding('uv'); /** @@ -180,22 +179,6 @@ function createEACCES(syscall, path) { return err; } -/** - * Creates an EXDEV error for cross-device link operations. - * @param {string} syscall The system call name - * @param {string} path The path - * @returns {Error} - */ -function createEXDEV(syscall, path) { - const err = new UVException({ - errno: UV_EXDEV, - syscall, - path, - }); - ErrorCaptureStackTrace(err, createEXDEV); - return err; -} - module.exports = { createENOENT, createENOTDIR, @@ -207,5 +190,4 @@ module.exports = { createEINVAL, createELOOP, createEACCES, - createEXDEV, }; diff --git a/test/parallel/test-vfs-file-handle-base.js b/test/parallel/test-vfs-file-handle-base.js new file mode 100644 index 00000000000000..e41880c23fdb3d --- /dev/null +++ b/test/parallel/test-vfs-file-handle-base.js @@ -0,0 +1,88 @@ +// Flags: --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-memory-coverage.js b/test/parallel/test-vfs-memory-coverage.js new file mode 100644 index 00000000000000..5bdedf3614a82b --- /dev/null +++ b/test/parallel/test-vfs-memory-coverage.js @@ -0,0 +1,168 @@ +// Flags: --expose-internals +'use strict'; + +// Cover MemoryProvider edge cases that aren't reached by the standard +// public-API tests: numeric flags, symlink loops, dynamic content +// providers, and lazy-populated directories. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); +const fs = require('fs'); + +// Numeric open flags (mirrors fs.constants.O_*) must be normalised to +// their string equivalents. +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'orig'); + + // O_RDONLY (0) + let fd = myVfs.openSync('/file.txt', fs.constants.O_RDONLY); + myVfs.closeSync(fd); + + // O_RDWR + fd = myVfs.openSync('/file.txt', fs.constants.O_RDWR); + myVfs.closeSync(fd); + + // O_WRONLY | O_CREAT | O_TRUNC = 'w' + fd = myVfs.openSync('/created.txt', + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC); + myVfs.closeSync(fd); + + // O_WRONLY | O_CREAT | O_EXCL = 'wx' + fd = myVfs.openSync('/excl.txt', + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL); + myVfs.closeSync(fd); + + // 'wx' on existing file throws EEXIST + assert.throws( + () => myVfs.openSync('/file.txt', + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL), + { code: 'EEXIST' }); + + // O_APPEND | O_RDWR | O_CREAT + fd = myVfs.openSync('/app.txt', + fs.constants.O_APPEND | fs.constants.O_RDWR | fs.constants.O_CREAT); + myVfs.closeSync(fd); + + // O_APPEND | O_EXCL | O_RDWR | O_CREAT = 'ax+' + fd = myVfs.openSync('/axplus.txt', + fs.constants.O_APPEND | fs.constants.O_EXCL | + fs.constants.O_RDWR | fs.constants.O_CREAT); + myVfs.closeSync(fd); + + // Bogus non-string non-number: defaults to 'r' + fd = myVfs.openSync('/file.txt', null); + myVfs.closeSync(fd); +} + +// utimes with numeric (seconds) and Date arguments +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/u.txt', 'x'); + // numeric seconds branch + myVfs.utimesSync('/u.txt', 1000, 2000); + // Date branch + myVfs.utimesSync('/u.txt', new Date(3000000), new Date(4000000)); +} + +// Symlink loop detection — covers createELOOP path +{ + 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' }); +} + +// Direct entry manipulation (via internals) to cover dynamic content +// providers and lazy directory population — these features exist on +// MemoryEntry/MemoryProvider but have no public construction API. +{ + const { MemoryProvider } = require('internal/vfs/providers/memory'); + const provider = new MemoryProvider(); + + // Sync content provider + provider.openSync('/dyn-sync.txt', 'w').closeSync(); + // Reach into the entry to install a content provider + const lookup = (p) => provider.statSync(p) && p; // ensure exists; throws otherwise + lookup('/dyn-sync.txt'); + + // Use a custom content provider via the lazy populate mechanism. + const myVfs = vfs.create(provider); + // Lazy-populated directory via internal populate hook + // Manually wire a populate callback into a directory we create via mkdir + myVfs.mkdirSync('/lazy'); + // Pull the entry out via stat then poke populate via a small private channel: + // we simulate the populate flow by calling the public addFile-like helpers + // available on the scoped VFS object — these are only exposed via populate. + // So instead, write content and read it through the dynamic-content path + // by enabling a custom contentProvider via reading raw children. + + // The simplest way to exercise the dynamic content path is to read a file + // and write to it many times (geometric buffer growth), and to access an + // entry whose contentProvider returns a non-Buffer string. + myVfs.writeFileSync('/lazy/file.txt', 'x'.repeat(10)); + for (let i = 0; i < 5; i++) { + myVfs.appendFileSync('/lazy/file.txt', 'y'.repeat(2 ** i)); + } + assert.ok(myVfs.readFileSync('/lazy/file.txt').length > 10); +} + +// readdir basic — withFileTypes false returns names +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d'); + myVfs.writeFileSync('/d/a.txt', ''); + const names = myVfs.readdirSync('/d'); + assert.deepStrictEqual(names, ['a.txt']); +} + +// rename onto an existing 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); +} + +// rmdir on non-empty directory throws ENOTEMPTY +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d'); + myVfs.writeFileSync('/d/x', ''); + assert.throws(() => myVfs.rmdirSync('/d'), { code: 'ENOTEMPTY' }); +} + +// chown / chmod / lutimes +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/p.txt', 'x'); + myVfs.symlinkSync('/p.txt', '/lk'); + myVfs.chmodSync('/p.txt', 0o600); + myVfs.chownSync('/p.txt', 100, 200); + myVfs.lutimesSync('/lk', new Date(0), new Date(0)); + const st = myVfs.statSync('/p.txt'); + assert.strictEqual(st.uid, 100); + assert.strictEqual(st.gid, 200); +} + +// MemoryProvider basic watch + watchAsync + watchFile +{ + const provider = new vfs.MemoryProvider(); + assert.strictEqual(provider.supportsWatch, true); + const myVfs = vfs.create(provider); + myVfs.writeFileSync('/wf.txt', 'a'); + + const w = myVfs.watch('/wf.txt'); + w.close(); + + const ai = myVfs.promises.watch('/wf.txt'); + ai.return().then(common.mustCall()); + + const listener = () => {}; + myVfs.watchFile('/wf.txt', { interval: 1000, persistent: false }, listener); + myVfs.unwatchFile('/wf.txt', listener); +} diff --git a/test/parallel/test-vfs-misc-coverage.js b/test/parallel/test-vfs-misc-coverage.js new file mode 100644 index 00000000000000..c8774da21457ae --- /dev/null +++ b/test/parallel/test-vfs-misc-coverage.js @@ -0,0 +1,131 @@ +'use strict'; + +// Cover small uncovered branches across the VFS subsystem. + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +// vfs.create with first arg as options (not a provider, no openSync method) +{ + const myVfs = vfs.create({ emitExperimentalWarning: false }); + assert.ok(myVfs); + assert.ok(myVfs.provider instanceof vfs.MemoryProvider); +} + +// new VirtualFileSystem(options) directly +{ + const myVfs = new vfs.VirtualFileSystem({ emitExperimentalWarning: false }); + assert.ok(myVfs); + // emitExperimentalWarning option is validated as boolean + assert.throws(() => + new vfs.VirtualFileSystem({ emitExperimentalWarning: 'not-bool' }), + { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// existsSync swallows path errors and returns false +{ + const myVfs = vfs.create(); + assert.strictEqual(myVfs.existsSync('/nope'), false); +} + +// readdir({ withFileTypes: true, recursive: true }) — covers the recursive +// dirent path that fixes parentPath when names contain slashes. +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/r/a/b', { recursive: true }); + myVfs.writeFileSync('/r/top.txt', 'x'); + myVfs.writeFileSync('/r/a/b/leaf.txt', 'y'); + + const dirents = myVfs.readdirSync('/r', { withFileTypes: true, recursive: true }); + // Find the leaf in the recursive listing + const leaf = dirents.find((d) => d.name === 'leaf.txt'); + assert.ok(leaf, 'leaf entry expected'); + assert.strictEqual(leaf.parentPath, '/r/a/b'); + + // Top-level entry has parentPath = root + const top = dirents.find((d) => d.name === 'top.txt'); + assert.ok(top); + assert.strictEqual(top.parentPath, '/r'); +} + +// stats bigint paths for directories, symlinks, and zero-stats +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/dir'); + myVfs.symlinkSync('/dir', '/link'); + myVfs.writeFileSync('/file.txt', 'x'); + + const dirStat = myVfs.statSync('/dir', { bigint: true }); + assert.strictEqual(typeof dirStat.size, 'bigint'); + assert.strictEqual(dirStat.isDirectory(), true); + + const linkStat = myVfs.lstatSync('/link', { bigint: true }); + assert.strictEqual(typeof linkStat.size, 'bigint'); + assert.strictEqual(linkStat.isSymbolicLink(), true); +} + +// watchFile on a missing file should emit zero-stats (covers createZeroStats). +// The initial poll establishes prev as zero-stats; once the file is created, +// the listener sees prev with size 0n. +{ + const myVfs = vfs.create(); + const watcher = myVfs.watchFile('/missing.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.txt'); + }, 1)); + setTimeout(() => myVfs.writeFileSync('/missing.txt', 'now-here'), 80); + setTimeout(() => myVfs.unwatchFile('/missing.txt'), 500); + if (watcher && watcher.unref) watcher.unref(); +} + +// VirtualDir read callback error path: pre-closed dir +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d'); + const dir = myVfs.opendirSync('/d'); + dir.closeSync(); + dir.read(common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_DIR_CLOSED'); + })); + // entries() iterator on a closed dir throws when iterated + (async () => { + await assert.rejects( + (async () => { for await (const _ of dir.entries()) {} })(), // eslint-disable-line no-unused-vars + { 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 myVfs = vfs.create(); + myVfs.mkdirSync('/d2'); + const dir = myVfs.opendirSync('/d2'); + await dir.close(); +})().then(common.mustCall()); + +// createReadStream path getter coverage already in streams-misc; here we +// destroy the stream early to cover _destroy + _close paths. +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/x.txt', 'data'); + const rs = myVfs.createReadStream('/x.txt'); + rs.on('error', () => {}); + rs.destroy(); +} + +// MemoryProvider setReadOnly — once read-only, writes throw EROFS +{ + const provider = new vfs.MemoryProvider(); + const myVfs = vfs.create(provider); + myVfs.writeFileSync('/a.txt', 'x'); + provider.setReadOnly(); + assert.strictEqual(provider.readonly, true); + assert.throws(() => myVfs.writeFileSync('/a.txt', 'y'), { code: 'EROFS' }); +} From 7e664db5440882d6e4bd026bee8ad16eb1fd8ac4 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 2 May 2026 21:58:30 +0200 Subject: [PATCH 07/14] test: push every VFS file to >=95% line coverage Adds targeted tests covering the lazy population, dynamic content provider, readonly-mode, and symlink-traversal paths in MemoryProvider; the path-escape and RealFileHandle EBADF paths in RealFSProvider; the abort/buffer-encoding/recursive watch paths in VFSWatcher; and the empty-file / EBADF fd / explicit-fd-with-start paths in the streams. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- test/parallel/test-vfs-memory-coverage.js | 239 ++++++++++++++++++--- test/parallel/test-vfs-real-coverage.js | 141 ++++++++++++ test/parallel/test-vfs-streams-coverage.js | 69 ++++++ test/parallel/test-vfs-watcher-coverage.js | 124 +++++++++++ 4 files changed, 546 insertions(+), 27 deletions(-) create mode 100644 test/parallel/test-vfs-real-coverage.js create mode 100644 test/parallel/test-vfs-streams-coverage.js create mode 100644 test/parallel/test-vfs-watcher-coverage.js diff --git a/test/parallel/test-vfs-memory-coverage.js b/test/parallel/test-vfs-memory-coverage.js index 5bdedf3614a82b..cb2581901be9b8 100644 --- a/test/parallel/test-vfs-memory-coverage.js +++ b/test/parallel/test-vfs-memory-coverage.js @@ -75,38 +75,81 @@ const fs = require('fs'); assert.throws(() => myVfs.realpathSync('/a'), { code: 'ELOOP' }); } -// Direct entry manipulation (via internals) to cover dynamic content -// providers and lazy directory population — these features exist on -// MemoryEntry/MemoryProvider but have no public construction API. +// Geometric buffer growth in writeSync — append many times to exercise +// the doubling path. { - const { MemoryProvider } = require('internal/vfs/providers/memory'); + const myVfs = vfs.create(); + myVfs.writeFileSync('/grow.txt', 'x'.repeat(10)); + for (let i = 0; i < 8; i++) { + myVfs.appendFileSync('/grow.txt', 'y'.repeat(2 ** i)); + } + assert.ok(myVfs.readFileSync('/grow.txt').length > 250); +} + +// Dynamic content providers (sync) and lazy directory population. +// These features have no public construction API, so we drive them +// directly through MemoryEntry / MemoryProvider internals. +{ + const memMod = require('internal/vfs/providers/memory'); + const { MemoryProvider } = memMod; const provider = new MemoryProvider(); - // Sync content provider - provider.openSync('/dyn-sync.txt', 'w').closeSync(); - // Reach into the entry to install a content provider - const lookup = (p) => provider.statSync(p) && p; // ensure exists; throws otherwise - lookup('/dyn-sync.txt'); + // Lazy-populated directory: install a populate callback on an existing + // directory entry via the internal kRoot symbol. + const symbols = Object.getOwnPropertySymbols(provider); + const kRoot = symbols.find((s) => s.description === 'kRoot'); + assert.ok(kRoot, 'kRoot symbol expected on MemoryProvider'); + const root = provider[kRoot]; + + // Manually create a lazy directory entry. + const memEntryProto = Object.getPrototypeOf(root); + const dir = Object.create(memEntryProto); + dir.type = 1; // TYPE_DIR + dir.mode = 0o755; + dir.children = null; + dir.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'); + }; + dir.populated = false; + dir.nlink = 1; + dir.uid = 0; + dir.gid = 0; + const t = Date.now(); + dir.atime = t; + dir.mtime = t; + dir.ctime = t; + dir.birthtime = t; + // Borrow methods from an existing entry + 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); + + // Need a SafeMap children for the directory to behave correctly. + dir.children = new Map(); + root.children.set('lazy', dir); - // Use a custom content provider via the lazy populate mechanism. const myVfs = vfs.create(provider); - // Lazy-populated directory via internal populate hook - // Manually wire a populate callback into a directory we create via mkdir - myVfs.mkdirSync('/lazy'); - // Pull the entry out via stat then poke populate via a small private channel: - // we simulate the populate flow by calling the public addFile-like helpers - // available on the scoped VFS object — these are only exposed via populate. - // So instead, write content and read it through the dynamic-content path - // by enabling a custom contentProvider via reading raw children. - - // The simplest way to exercise the dynamic content path is to read a file - // and write to it many times (geometric buffer growth), and to access an - // entry whose contentProvider returns a non-Buffer string. - myVfs.writeFileSync('/lazy/file.txt', 'x'.repeat(10)); - for (let i = 0; i < 5; i++) { - myVfs.appendFileSync('/lazy/file.txt', 'y'.repeat(2 ** i)); - } - assert.ok(myVfs.readFileSync('/lazy/file.txt').length > 10); + + // Reading the lazy directory triggers populate + const entries = myVfs.readdirSync('/lazy'); + assert.deepStrictEqual(entries.sort(), ['dyn.txt', 'hello.txt', 'link.txt', 'subdir']); + + // Static lazy file content + assert.strictEqual(myVfs.readFileSync('/lazy/hello.txt', 'utf8'), 'lazy hello'); + + // Dynamic content provider (sync, returns string) + assert.strictEqual(myVfs.readFileSync('/lazy/dyn.txt', 'utf8'), 'dynamic-string'); + + // Dynamic content provider (async via promises) + myVfs.promises.readFile('/lazy/dyn.txt', 'utf8').then(common.mustCall((s) => { + assert.strictEqual(s, 'dynamic-string'); + })); } // readdir basic — withFileTypes false returns names @@ -149,6 +192,148 @@ const fs = require('fs'); assert.strictEqual(st.gid, 200); } +// utimes with string time (treated as DateNow) +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/u2.txt', 'x'); + myVfs.utimesSync('/u2.txt', 'now', 'now'); +} + +// readdir with mixed entry types (file, dir, symlink) — exercises +// non-recursive Dirent type branches. +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d'); + myVfs.writeFileSync('/d/a.txt', 'x'); + myVfs.mkdirSync('/d/sub'); + myVfs.symlinkSync('a.txt', '/d/lnk'); + const dirents = myVfs.readdirSync('/d', { withFileTypes: true }); + const types = dirents.map((d) => d.name + ':' + (d.isFile() ? 'f' : d.isDirectory() ? 'd' : d.isSymbolicLink() ? 'l' : '?')); + assert.ok(types.includes('a.txt:f')); + assert.ok(types.includes('sub:d')); + assert.ok(types.includes('lnk:l')); +} + +// rename: type mismatches throw +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'x'); + myVfs.mkdirSync('/dir'); + // rename file onto dir → EISDIR + assert.throws(() => myVfs.renameSync('/file.txt', '/dir'), { code: 'EISDIR' }); + // rename dir onto file → ENOTDIR + assert.throws(() => myVfs.renameSync('/dir', '/file.txt'), { code: 'ENOTDIR' }); +} + +// link to a directory throws EINVAL +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d'); + assert.throws(() => myVfs.linkSync('/d', '/d-link'), { code: 'EINVAL' }); +} + +// link to 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' }); +} + +// symlink with existing target throws EEXIST +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/a.txt', 'x'); + assert.throws(() => myVfs.symlinkSync('/a.txt', '/a.txt'), { code: 'EEXIST' }); +} + +// readonly write paths throw EROFS +{ + const provider = new vfs.MemoryProvider(); + const myVfs = vfs.create(provider); + myVfs.writeFileSync('/f.txt', 'x'); + myVfs.mkdirSync('/d'); + myVfs.symlinkSync('/f.txt', '/lnk'); + provider.setReadOnly(); + assert.throws(() => myVfs.openSync('/f.txt', 'w'), { code: 'EROFS' }); + assert.throws(() => myVfs.unlinkSync('/f.txt'), { code: 'EROFS' }); + assert.throws(() => myVfs.rmdirSync('/d'), { code: 'EROFS' }); + assert.throws(() => myVfs.renameSync('/f.txt', '/g.txt'), { code: 'EROFS' }); + assert.throws(() => myVfs.linkSync('/f.txt', '/h.txt'), { code: 'EROFS' }); + assert.throws(() => myVfs.symlinkSync('/x', '/y'), { code: 'EROFS' }); + assert.throws(() => myVfs.mkdirSync('/d2'), { code: 'EROFS' }); +} + +// open a file via a symlinked parent directory (covers the parent-symlink +// follow path in #ensureParent) +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/real-dir'); + myVfs.writeFileSync('/real-dir/file.txt', 'hello'); + myVfs.symlinkSync('/real-dir', '/link-dir'); + // Read through the symlinked directory + assert.strictEqual(myVfs.readFileSync('/link-dir/file.txt', 'utf8'), 'hello'); + // Write through the symlinked directory + myVfs.writeFileSync('/link-dir/new.txt', 'new'); + assert.strictEqual(myVfs.readFileSync('/real-dir/new.txt', 'utf8'), 'new'); +} + +// ENOTDIR mid-path: writing through a non-directory parent fails ENOTDIR +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/file.txt', 'x'); + // ensureParent walks the path and hits a file in the middle → ENOTDIR + assert.throws(() => myVfs.writeFileSync('/file.txt/oops', 'y'), + { code: 'ENOTDIR' }); +} + +// Dynamic content provider returning a Promise — sync API throws +{ + const memMod = require('internal/vfs/providers/memory'); + const { MemoryProvider } = memMod; + const provider = new MemoryProvider(); + const symbols = Object.getOwnPropertySymbols(provider); + const kRoot = symbols.find((s) => s.description === 'kRoot'); + const root = provider[kRoot]; + + // Create a file with an async content provider + const memEntryProto = Object.getPrototypeOf(root); + const fileEntry = Object.create(memEntryProto); + fileEntry.type = 0; // TYPE_FILE + fileEntry.mode = 0o644; + fileEntry.content = Buffer.alloc(0); + fileEntry.contentProvider = async () => 'async-only'; + fileEntry.children = null; + fileEntry.target = null; + fileEntry.populate = null; + fileEntry.populated = true; + fileEntry.nlink = 1; + fileEntry.uid = 0; + fileEntry.gid = 0; + const t = Date.now(); + fileEntry.atime = t; + fileEntry.mtime = t; + fileEntry.ctime = t; + fileEntry.birthtime = t; + fileEntry.isFile = root.isFile.bind(fileEntry); + fileEntry.isDirectory = root.isDirectory.bind(fileEntry); + fileEntry.isSymbolicLink = root.isSymbolicLink.bind(fileEntry); + fileEntry.isDynamic = root.isDynamic.bind(fileEntry); + fileEntry.getContentSync = root.getContentSync.bind(fileEntry); + fileEntry.getContentAsync = root.getContentAsync.bind(fileEntry); + + root.children.set('async-only.txt', fileEntry); + + const myVfs = vfs.create(provider); + // Sync read with async provider throws ERR_INVALID_STATE + assert.throws(() => myVfs.readFileSync('/async-only.txt'), + { code: 'ERR_INVALID_STATE' }); + // Async read works + myVfs.promises.readFile('/async-only.txt', 'utf8').then(common.mustCall((s) => { + assert.strictEqual(s, 'async-only'); + })); +} + // MemoryProvider basic watch + watchAsync + watchFile { const provider = new vfs.MemoryProvider(); diff --git a/test/parallel/test-vfs-real-coverage.js b/test/parallel/test-vfs-real-coverage.js new file mode 100644 index 00000000000000..57400aca57ee66 --- /dev/null +++ b/test/parallel/test-vfs-real-coverage.js @@ -0,0 +1,141 @@ +'use strict'; + +// Cover RealFSProvider edge cases: path-escape rejection, RealFileHandle +// methods, error paths. + +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-coverage'); +fs.mkdirSync(root, { recursive: true }); + +const myVfs = vfs.create(new vfs.RealFSProvider(root)); + +// RealFileHandle methods after close throw EBADF +(async () => { + 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() / closeSync() are no-ops + handle.closeSync(); + await handle.close(); +})().then(common.mustCall()); + +// RealFileHandle read/write/stat/truncate happy path +(async () => { + 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); + const r = handle.readSync(buf, 0, 3, 0); + assert.strictEqual(r, 3); + assert.strictEqual(buf.toString(), 'abc'); + + const r2 = await handle.read(Buffer.alloc(3), 0, 3, 3); + assert.strictEqual(r2.bytesRead, 3); + assert.strictEqual(r2.buffer.toString(), 'def'); + + const wbuf = Buffer.from('zz'); + handle.writeSync(wbuf, 0, 2, 0); + const w = await handle.write(Buffer.from('YY'), 0, 2, 4); + assert.strictEqual(w.bytesWritten, 2); + + // statSync / stat + const s1 = handle.statSync(); + const s2 = await handle.stat(); + assert.strictEqual(s1.size, s2.size); + + // readFileSync / readFile (path-based, not fd-based) + assert.ok(handle.readFileSync().length > 0); + assert.ok((await handle.readFile()).length > 0); + + // writeFileSync / writeFile overwrite the entire file (path-based) + handle.writeFileSync('OVERWRITTEN'); + assert.strictEqual(handle.readFileSync('utf8'), 'OVERWRITTEN'); + await handle.writeFile('async-overwrite'); + assert.strictEqual(await handle.readFile('utf8'), 'async-overwrite'); + + // truncateSync / truncate + handle.truncateSync(3); + await handle.truncate(2); + + await handle.close(); +})().then(common.mustCall()); + +// Path-escape rejection: VFS paths cannot escape rootPath via .. segments +(async () => { + // ../ patterns get resolved into the root by path.resolve, so they never + // actually escape — but we still verify the error surface. + await assert.rejects(myVfs.promises.stat('/../../../etc/passwd'), + { code: 'ENOENT' }); + + // Symbolic link that points outside the root → readlinkSync returns the + // real (untranslated) target path; realpath rejects with EACCES because + // the resolved path escaped root. + fs.writeFileSync(path.join(tmpdir.path, 'outside.txt'), 'forbidden'); + fs.symlinkSync(path.join(tmpdir.path, 'outside.txt'), + path.join(root, 'esc-link')); + + const target = myVfs.readlinkSync('/esc-link'); + // Target is absolute and outside root → returned verbatim + assert.strictEqual(target, path.join(tmpdir.path, 'outside.txt')); + const target2 = await myVfs.promises.readlink('/esc-link'); + assert.strictEqual(target2, path.join(tmpdir.path, 'outside.txt')); + + // realpath through the escape-link rejects with ENOENT (the security + // check at #resolvePath catches the escape after fs.realpathSync resolves + // through it). + assert.throws(() => myVfs.realpathSync('/esc-link'), + { code: 'EACCES' }); + await assert.rejects(myVfs.promises.realpath('/esc-link'), + { code: 'EACCES' }); +})().then(common.mustCall()); + +// Symlink with relative target (within root) — readlink returns target as-is +(async () => { + 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'); +})().then(common.mustCall()); + +// access (sync + async) on existing and missing files +(async () => { + await myVfs.promises.writeFile('/acc.txt', 'x'); + myVfs.accessSync('/acc.txt'); + await myVfs.promises.access('/acc.txt'); + await assert.rejects(myVfs.promises.access('/missing.txt'), + { code: 'ENOENT' }); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-streams-coverage.js b/test/parallel/test-vfs-streams-coverage.js new file mode 100644 index 00000000000000..cbaf4ed2fbac8c --- /dev/null +++ b/test/parallel/test-vfs-streams-coverage.js @@ -0,0 +1,69 @@ +'use strict'; + +// Cover stream paths not exercised by other tests: +// - write/read on destroyed/closed streams (EBADF) +// - empty file read (push(null) early path) +// - WriteStream with explicit fd + start position +// - close() error swallowed + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Empty file → ReadStream pushes null on the first read (remaining <= 0) +{ + myVfs.writeFileSync('/empty.txt', ''); + const rs = myVfs.createReadStream('/empty.txt'); + rs.on('data', () => assert.fail('no data expected')); + rs.on('end', common.mustCall()); +} + +// 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 }); + // Close the fd before the stream's nextTick 'open' event runs. + // The first _read will see the now-invalid fd in the lazy load path. + myVfs.closeSync(fd); + rs.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'EBADF'); + })); + rs.resume(); // trigger _read +} + +// 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()); + +// WriteStream synchronously failing to open → destroys on next tick +{ + // openSync on /missing-dir/file.txt without recursive parents fails ENOENT + const ws = myVfs.createWriteStream('/missing-dir/foo.txt', { flags: 'wx' }); + ws.on('error', common.mustCall((err) => { + assert.ok(err); + })); +} + +// _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-watcher-coverage.js b/test/parallel/test-vfs-watcher-coverage.js new file mode 100644 index 00000000000000..91fee0e5afd47e --- /dev/null +++ b/test/parallel/test-vfs-watcher-coverage.js @@ -0,0 +1,124 @@ +'use strict'; + +// Cover VFSWatcher edge cases. Run blocks sequentially. Use distinct +// content lengths so size-based stat-change detection always fires +// (mtime granularity is millisecond which can collide on synchronous +// writes within the same poll tick). + +const common = require('../common'); +const assert = require('assert'); +const { once } = require('events'); +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(); + } + + // Listener add/remove + ref/unref + { + 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(); + } + + // Buffer encoding — filename arrives as Buffer + { + 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', 'bbbbbbbb'); + const [eventType, filename] = await changed; + assert.strictEqual(eventType, 'change'); + assert.ok(Buffer.isBuffer(filename)); + watcher.close(); + } + + // Recursive directory watch — observe a 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(); + } + + // 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(); + } + + // Async iterable: events queued and drained via next() + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/q.txt', 'a'); + const iter = myVfs.promises.watch('/q.txt', { interval: 25 }); + myVfs.writeFileSync('/q.txt', 'bbbbbbbb'); + const r = await iter.next(); + if (!r.done) assert.strictEqual(r.value.eventType, 'change'); + await iter.return(); + } + + // VFSStatWatcher fires on content change + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/sw.txt', 'a'); + let listener; + const fired = new Promise((resolve) => { + listener = (curr, prev) => { + assert.strictEqual(typeof curr.size, 'number'); + assert.strictEqual(typeof prev.size, 'number'); + resolve(); + }; + }); + myVfs.watchFile('/sw.txt', { interval: 25 }, listener); + myVfs.writeFileSync('/sw.txt', 'changed!!!!'); + await fired; + myVfs.unwatchFile('/sw.txt', listener); + } + + // 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(); + } +})().then(common.mustCall()); From f23c8d0bfd1e0cd5a74b6ee1b9223b268d4e709a Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 3 May 2026 14:07:16 +0200 Subject: [PATCH 08/14] test: push branch coverage to 95%+ Adds direct unit tests for stats default-option paths (including the process.getuid?.() fallback), file-handle base-class branches, the empty-options provider write/append paths, the access-mode permission denials, the watcher closed-state and async-iterable resolver-drain branches, and various RealFSProvider escape and EBADF paths. Brings overall branch coverage from 89% to 95.7%, and stats.js to 100% branch coverage. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- .../parallel/test-vfs-file-handle-branches.js | 98 ++++++ test/parallel/test-vfs-memory-branches.js | 152 +++++++++ test/parallel/test-vfs-misc-coverage.js | 44 +++ test/parallel/test-vfs-provider-branches.js | 69 ++++ test/parallel/test-vfs-real-coverage.js | 303 +++++++++++------- test/parallel/test-vfs-stats-defaults.js | 80 +++++ test/parallel/test-vfs-streams-coverage.js | 44 +++ test/parallel/test-vfs-watcher-branches.js | 145 +++++++++ 8 files changed, 813 insertions(+), 122 deletions(-) create mode 100644 test/parallel/test-vfs-file-handle-branches.js create mode 100644 test/parallel/test-vfs-memory-branches.js create mode 100644 test/parallel/test-vfs-provider-branches.js create mode 100644 test/parallel/test-vfs-stats-defaults.js create mode 100644 test/parallel/test-vfs-watcher-branches.js diff --git a/test/parallel/test-vfs-file-handle-branches.js b/test/parallel/test-vfs-file-handle-branches.js new file mode 100644 index 00000000000000..a320e4f7af9128 --- /dev/null +++ b/test/parallel/test-vfs-file-handle-branches.js @@ -0,0 +1,98 @@ +// Flags: --expose-internals +'use strict'; + +// Cover branch paths in MemoryFileHandle (and base VirtualFileHandle). + +const common = require('../common'); +const assert = require('assert'); +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); +myVfs.writeFileSync('/file.txt', 'hello world'); + +(async () => { + // readv with explicit position and a partial read at EOF + const handle = await myVfs.provider.open('/file.txt', 'r'); + const b1 = Buffer.alloc(5); + const b2 = Buffer.alloc(20); // larger than remaining → partial → break + const r = await handle.readv([b1, b2], 0); + assert.strictEqual(b1.toString(), 'hello'); + assert.strictEqual(r.bytesRead, 11); + await handle.close(); + + // writev with explicit position + 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'); + + // appendFile with string + encoding option + 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()); + +// #checkReadable: 'w' mode rejects reads 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()); + +// #checkWritable: 'r' mode rejects writes 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()); + +// writeFileSync with string + encoding +(async () => { + const handle = await myVfs.provider.open('/se.txt', 'w+'); + await handle.writeFile('héllo', { encoding: 'utf8' }); + const got = await handle.readFile('utf8'); + assert.strictEqual(got, 'héllo'); + await handle.close(); +})().then(common.mustCall()); + +// truncateSync extending past current size +(async () => { + const handle = await myVfs.provider.open('/grow.txt', 'w+'); + await handle.writeFile('abc'); + await handle.truncate(10); + const stats = await handle.stat(); + assert.strictEqual(stats.size, 10); + // Content has zero-filled extension + const data = await handle.readFile(); + assert.strictEqual(data.length, 10); + await handle.close(); +})().then(common.mustCall()); + +// MemoryFileHandle without a #getStats callback throws ERR_INVALID_STATE +{ + const { MemoryFileHandle } = require('internal/vfs/file_handle'); + // Pass undefined as getStats — entry is null so the dynamic-content + // branches don't trigger; statSync falls into the "stats not available" + // path. + const h = new MemoryFileHandle('/x', 'r', 0o644, Buffer.alloc(0), null, undefined); + assert.throws(() => h.statSync(), { code: 'ERR_INVALID_STATE' }); +} + +// readv on closed handle rejects 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-memory-branches.js b/test/parallel/test-vfs-memory-branches.js new file mode 100644 index 00000000000000..a2ec2f248ea00b --- /dev/null +++ b/test/parallel/test-vfs-memory-branches.js @@ -0,0 +1,152 @@ +// Flags: --expose-internals +'use strict'; + +// Cover branch paths in MemoryProvider that aren't reached by the +// happy-path tests. + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const vfs = require('node:vfs'); + +// utimes with non-number / non-string / non-object → falls through to +// `return time;` in toMs. +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/u.txt', 'x'); + myVfs.utimesSync('/u.txt', null, undefined); +} + +// utimes with object Date instances +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/u2.txt', 'x'); + myVfs.utimesSync('/u2.txt', new Date(0), new Date(1)); +} + +// normalizeFlags: every numeric flag combination +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/seed.txt', 'x'); + // 'a' = append + write only (no rdwr) + myVfs.openSync('/append.txt', + fs.constants.O_APPEND | fs.constants.O_CREAT | + fs.constants.O_WRONLY).closeSync; + // 'a+' = append + rdwr + myVfs.openSync('/append-plus.txt', + fs.constants.O_APPEND | fs.constants.O_CREAT | + fs.constants.O_RDWR).closeSync; + // 'ax' = append + excl + myVfs.openSync('/ax.txt', + fs.constants.O_APPEND | fs.constants.O_EXCL | + fs.constants.O_CREAT | fs.constants.O_WRONLY).closeSync; + // 'r+' = rdwr (no write/append/excl) + myVfs.openSync('/seed.txt', fs.constants.O_RDWR).closeSync; +} + +// mkdir recursive: an intermediate path is a regular file → ENOTDIR +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/blocker', 'x'); + assert.throws( + () => myVfs.mkdirSync('/blocker/sub', { recursive: true }), + { code: 'ENOTDIR' }, + ); +} + +// mkdir with explicit mode +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/d-mode', { mode: 0o700 }); + const st = myVfs.statSync('/d-mode'); + assert.strictEqual(st.mode & 0o777, 0o700); + + myVfs.mkdirSync('/r-mode/sub/deep', { recursive: true, mode: 0o700 }); + assert.strictEqual( + myVfs.statSync('/r-mode/sub/deep').mode & 0o777, 0o700); +} + +// rename within the same parent +{ + 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); +} + +// Dynamic content provider returning a Buffer (not string) +{ + const memMod = require('internal/vfs/providers/memory'); + const { MemoryProvider } = memMod; + const provider = new MemoryProvider(); + const symbols = Object.getOwnPropertySymbols(provider); + const kRoot = symbols.find((s) => s.description === 'kRoot'); + const root = provider[kRoot]; + const memEntryProto = Object.getPrototypeOf(root); + + const fileEntry = Object.create(memEntryProto); + fileEntry.type = 0; // TYPE_FILE + fileEntry.mode = 0o644; + fileEntry.content = Buffer.alloc(0); + fileEntry.contentProvider = () => Buffer.from('buffer-content'); + fileEntry.children = null; + fileEntry.target = null; + fileEntry.populate = null; + fileEntry.populated = true; + fileEntry.nlink = 1; + fileEntry.uid = 0; + fileEntry.gid = 0; + const t = Date.now(); + fileEntry.atime = t; + fileEntry.mtime = t; + fileEntry.ctime = t; + fileEntry.birthtime = t; + fileEntry.isFile = root.isFile.bind(fileEntry); + fileEntry.isDirectory = root.isDirectory.bind(fileEntry); + fileEntry.isSymbolicLink = root.isSymbolicLink.bind(fileEntry); + fileEntry.isDynamic = root.isDynamic.bind(fileEntry); + fileEntry.getContentSync = root.getContentSync.bind(fileEntry); + fileEntry.getContentAsync = root.getContentAsync.bind(fileEntry); + root.children.set('buf-dyn.txt', fileEntry); + + const myVfs = vfs.create(provider); + assert.strictEqual(myVfs.readFileSync('/buf-dyn.txt', 'utf8'), 'buffer-content'); +} + +// resolveSymlinkTarget with absolute target (root-relative) within 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'); +} + +// Lookup root path (resolves to root via early return) +{ + const myVfs = vfs.create(); + const st = myVfs.statSync('/'); + assert.ok(st.isDirectory()); +} + +// Symlink loop in intermediate path (ELOOP) +{ + const myVfs = vfs.create(); + myVfs.symlinkSync('/loop2', '/loop1'); + myVfs.symlinkSync('/loop1', '/loop2'); + assert.throws(() => myVfs.statSync('/loop1/sub'), + { code: 'ELOOP' }); +} + +// rename with same destination throws when types differ — exercises +// the existingDest type-mismatch checks already; here cover the +// successful overwrite of a file by a file. +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/x.txt', 'old'); + myVfs.writeFileSync('/y.txt', 'new'); + myVfs.renameSync('/y.txt', '/x.txt'); + assert.strictEqual(myVfs.readFileSync('/x.txt', 'utf8'), 'new'); +} diff --git a/test/parallel/test-vfs-misc-coverage.js b/test/parallel/test-vfs-misc-coverage.js index c8774da21457ae..5e4256875c657f 100644 --- a/test/parallel/test-vfs-misc-coverage.js +++ b/test/parallel/test-vfs-misc-coverage.js @@ -129,3 +129,47 @@ const vfs = require('node:vfs'); assert.strictEqual(provider.readonly, true); assert.throws(() => myVfs.writeFileSync('/a.txt', 'y'), { code: 'EROFS' }); } + +// existsSync swallows ALL errors from the provider, not just ENOENT +{ + // Use a custom provider whose existsSync throws + class ThrowingProvider extends vfs.VirtualProvider { + existsSync() { throw new Error('boom'); } + } + const myVfs = vfs.create(new ThrowingProvider()); + assert.strictEqual(myVfs.existsSync('/anything'), false); +} + +// opendirSync without options object (covers the `options?.recursive` undefined branch) +{ + const myVfs = vfs.create(); + myVfs.mkdirSync('/od'); + myVfs.writeFileSync('/od/a.txt', ''); + const dir = myVfs.opendirSync('/od'); + dir.closeSync(); +} + +// mkdtemp callback failure path (mkdtempSync throws because parent is missing) +{ + const myVfs = vfs.create(); + myVfs.mkdtemp('/missing/prefix-', common.mustCall((err) => { + assert.ok(err); + })); +} + +// watch with listener as 2nd argument +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/lf.txt', 'a'); + const w = myVfs.watch('/lf.txt', () => {}); + w.close(); +} + +// watchFile with listener as 2nd argument +{ + const myVfs = vfs.create(); + myVfs.writeFileSync('/lf2.txt', 'a'); + const listener = () => {}; + myVfs.watchFile('/lf2.txt', listener); + myVfs.unwatchFile('/lf2.txt', listener); +} diff --git a/test/parallel/test-vfs-provider-branches.js b/test/parallel/test-vfs-provider-branches.js new file mode 100644 index 00000000000000..27cd1ee44ab97e --- /dev/null +++ b/test/parallel/test-vfs-provider-branches.js @@ -0,0 +1,69 @@ +'use strict'; + +// Cover branch paths in provider.js — explicit options.flag / options.mode +// for writeFile/appendFile, and the access-mode permission denials. + +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; + +// writeFile / writeFileSync 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'); + + // promises path + myVfs.promises.writeFile('/b.txt', 'world', { flag: 'w', mode: 0o600 }) + .then(common.mustCall(() => { + assert.strictEqual(myVfs.readFileSync('/b.txt', 'utf8'), 'world'); + })); +} + +// appendFile / appendFileSync 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'); + })); +} + +// access permission denials — chmod the file to a permission-restricted mode +// so that R_OK / W_OK / X_OK each trigger EACCES via #checkAccessMode. +{ + 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) — existence-only check, no permission needed + myVfs.accessSync('/no-r.txt', 0); + // mode passed as null also exits early + myVfs.accessSync('/no-r.txt', null); +} diff --git a/test/parallel/test-vfs-real-coverage.js b/test/parallel/test-vfs-real-coverage.js index 57400aca57ee66..2ca0c0b5734fb9 100644 --- a/test/parallel/test-vfs-real-coverage.js +++ b/test/parallel/test-vfs-real-coverage.js @@ -1,7 +1,8 @@ 'use strict'; // Cover RealFSProvider edge cases: path-escape rejection, RealFileHandle -// methods, error paths. +// methods, error paths. Run sequentially to avoid fd-recycling races +// between independent (async () => {})() blocks. const common = require('../common'); const tmpdir = require('../common/tmpdir'); @@ -16,126 +17,184 @@ fs.mkdirSync(root, { recursive: true }); const myVfs = vfs.create(new vfs.RealFSProvider(root)); -// RealFileHandle methods after close throw EBADF (async () => { - 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() / closeSync() are no-ops - handle.closeSync(); - await handle.close(); -})().then(common.mustCall()); - -// RealFileHandle read/write/stat/truncate happy path -(async () => { - 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); - const r = handle.readSync(buf, 0, 3, 0); - assert.strictEqual(r, 3); - assert.strictEqual(buf.toString(), 'abc'); - - const r2 = await handle.read(Buffer.alloc(3), 0, 3, 3); - assert.strictEqual(r2.bytesRead, 3); - assert.strictEqual(r2.buffer.toString(), 'def'); - - const wbuf = Buffer.from('zz'); - handle.writeSync(wbuf, 0, 2, 0); - const w = await handle.write(Buffer.from('YY'), 0, 2, 4); - assert.strictEqual(w.bytesWritten, 2); - - // statSync / stat - const s1 = handle.statSync(); - const s2 = await handle.stat(); - assert.strictEqual(s1.size, s2.size); - - // readFileSync / readFile (path-based, not fd-based) - assert.ok(handle.readFileSync().length > 0); - assert.ok((await handle.readFile()).length > 0); - - // writeFileSync / writeFile overwrite the entire file (path-based) - handle.writeFileSync('OVERWRITTEN'); - assert.strictEqual(handle.readFileSync('utf8'), 'OVERWRITTEN'); - await handle.writeFile('async-overwrite'); - assert.strictEqual(await handle.readFile('utf8'), 'async-overwrite'); - - // truncateSync / truncate - handle.truncateSync(3); - await handle.truncate(2); - - await handle.close(); -})().then(common.mustCall()); - -// Path-escape rejection: VFS paths cannot escape rootPath via .. segments -(async () => { - // ../ patterns get resolved into the root by path.resolve, so they never - // actually escape — but we still verify the error surface. - await assert.rejects(myVfs.promises.stat('/../../../etc/passwd'), - { code: 'ENOENT' }); - - // Symbolic link that points outside the root → readlinkSync returns the - // real (untranslated) target path; realpath rejects with EACCES because - // the resolved path escaped root. - fs.writeFileSync(path.join(tmpdir.path, 'outside.txt'), 'forbidden'); - fs.symlinkSync(path.join(tmpdir.path, 'outside.txt'), - path.join(root, 'esc-link')); - - const target = myVfs.readlinkSync('/esc-link'); - // Target is absolute and outside root → returned verbatim - assert.strictEqual(target, path.join(tmpdir.path, 'outside.txt')); - const target2 = await myVfs.promises.readlink('/esc-link'); - assert.strictEqual(target2, path.join(tmpdir.path, 'outside.txt')); - - // realpath through the escape-link rejects with ENOENT (the security - // check at #resolvePath catches the escape after fs.realpathSync resolves - // through it). - assert.throws(() => myVfs.realpathSync('/esc-link'), - { code: 'EACCES' }); - await assert.rejects(myVfs.promises.realpath('/esc-link'), - { code: 'EACCES' }); -})().then(common.mustCall()); - -// Symlink with relative target (within root) — readlink returns target as-is -(async () => { - 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'); -})().then(common.mustCall()); - -// access (sync + async) on existing and missing files -(async () => { - await myVfs.promises.writeFile('/acc.txt', 'x'); - myVfs.accessSync('/acc.txt'); - await myVfs.promises.access('/acc.txt'); - await assert.rejects(myVfs.promises.access('/missing.txt'), - { code: 'ENOENT' }); + // RealFileHandle methods after close throw EBADF + { + 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' }); + handle.closeSync(); + await handle.close(); + } + + // RealFileHandle read/write/stat/truncate happy path + { + 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); + const r = handle.readSync(buf, 0, 3, 0); + assert.strictEqual(r, 3); + assert.strictEqual(buf.toString(), 'abc'); + + const r2 = await handle.read(Buffer.alloc(3), 0, 3, 3); + assert.strictEqual(r2.bytesRead, 3); + assert.strictEqual(r2.buffer.toString(), 'def'); + + const wbuf = Buffer.from('zz'); + handle.writeSync(wbuf, 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(); + } + + // Path-escape rejection + { + await assert.rejects(myVfs.promises.stat('/../../../etc/passwd'), + { code: 'ENOENT' }); + + fs.writeFileSync(path.join(tmpdir.path, 'outside.txt'), 'forbidden'); + fs.symlinkSync(path.join(tmpdir.path, 'outside.txt'), + path.join(root, 'esc-link')); + + const target = myVfs.readlinkSync('/esc-link'); + assert.strictEqual(target, path.join(tmpdir.path, 'outside.txt')); + const target2 = await myVfs.promises.readlink('/esc-link'); + assert.strictEqual(target2, path.join(tmpdir.path, 'outside.txt')); + + assert.throws(() => myVfs.realpathSync('/esc-link'), + { code: 'EACCES' }); + await assert.rejects(myVfs.promises.realpath('/esc-link'), + { code: 'EACCES' }); + } + + // Relative-target symlink within root + { + 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'); + } + + // access existing/missing + { + await myVfs.promises.writeFile('/acc.txt', 'x'); + myVfs.accessSync('/acc.txt'); + await myVfs.promises.access('/acc.txt'); + await assert.rejects(myVfs.promises.access('/missing.txt'), + { code: 'ENOENT' }); + } + + // open async error + { + await assert.rejects(myVfs.provider.open('/missing.txt', 'r'), + { code: 'ENOENT' }); + } + + // RealFileHandle 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' }); + } + + // Symlink with relative target outside root → EACCES + { + assert.throws(() => + myVfs.symlinkSync('../../escape', '/bad-link'), + { code: 'EACCES' }); + await assert.rejects( + myVfs.promises.symlink('../../escape', '/bad-link2'), + { code: 'EACCES' }, + ); + } + + // realpath via second escape-link + { + fs.writeFileSync(path.join(tmpdir.path, 'outside2.txt'), 'forbid'); + fs.symlinkSync(path.join(tmpdir.path, 'outside2.txt'), + path.join(root, 'esc-link2')); + assert.throws(() => myVfs.realpathSync('/esc-link2'), + { code: 'EACCES' }); + await assert.rejects(myVfs.promises.realpath('/esc-link2'), + { code: 'EACCES' }); + } + + // Symlink whose absolute target equals root → readlink returns '/' + { + fs.symlinkSync(root, path.join(root, 'root-link2')); + assert.strictEqual(myVfs.readlinkSync('/root-link2'), '/'); + } + + // VFS path with leading-..-and-no-slash escapes via path.resolve + // (covers the post-resolve security check that rejects with ENOENT). + // Note: '/../etc' normalizes back to '/etc' under root via slice(1) + + // path.resolve, so it stays inside root. To trigger the escape branch + // we use a path that does NOT start with '/' so slice(1) leaves the + // '..' intact. + { + const escapeProvider = new vfs.RealFSProvider(root); + assert.throws(() => escapeProvider.statSync('../etc/passwd'), + { code: 'ENOENT' }); + } + + // RealFSProvider with a rootPath that ends in path.sep — exercises the + // `endsWith(path.sep) ? rootPath : rootPath + sep` branch. + { + const trailingRoot = root + path.sep; + fs.writeFileSync(path.join(root, 'tr.txt'), 'tr'); + const tProvider = new vfs.RealFSProvider(trailingRoot); + assert.strictEqual(tProvider.readFileSync('/tr.txt', 'utf8'), 'tr'); + } })().then(common.mustCall()); diff --git a/test/parallel/test-vfs-stats-defaults.js b/test/parallel/test-vfs-stats-defaults.js new file mode 100644 index 00000000000000..499ccbd0d51eb2 --- /dev/null +++ b/test/parallel/test-vfs-stats-defaults.js @@ -0,0 +1,80 @@ +// Flags: --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-streams-coverage.js b/test/parallel/test-vfs-streams-coverage.js index cbaf4ed2fbac8c..221468b0a2bdf9 100644 --- a/test/parallel/test-vfs-streams-coverage.js +++ b/test/parallel/test-vfs-streams-coverage.js @@ -67,3 +67,47 @@ const myVfs = vfs.create(); })); ws.write('x'); } + +// Read stream where the lazy read (vfd.entry.readFileSync) throws. +// Externally close the underlying virtual fd before _read runs but AFTER +// the constructor has stashed it, so vfd lookup succeeds but the entry +// read fails. We can simulate by destroying the virtual fd after the +// stream is created with autoClose:false. +{ + myVfs.writeFileSync('/lz.txt', 'data'); + const fd = myVfs.openSync('/lz.txt'); + const rs = myVfs.createReadStream('/lz.txt', { fd, autoClose: true }); + rs.on('error', common.mustCall(() => {})); + // Trigger _read on next tick; before that, close the fd via the vfs + // so the lazy lookup hits `if (!vfd)` (already covered) but #close in + // _destroy will swallow its own duplicate-close error. + myVfs.closeSync(fd); + rs.resume(); +} + +// Read stream with autoClose:true and an error during _read — covers +// the close-error swallow path inside #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 destroyed before write() — covers the destroyed-true branch +// in _write. +{ + const ws = myVfs.createWriteStream('/wd.txt'); + ws.on('error', () => {}); + ws.destroy(new Error('boom')); +} + +// Read stream with explicit start beyond file end → remaining <= 0 → push null +{ + 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()); +} diff --git a/test/parallel/test-vfs-watcher-branches.js b/test/parallel/test-vfs-watcher-branches.js new file mode 100644 index 00000000000000..49f99836c9235f --- /dev/null +++ b/test/parallel/test-vfs-watcher-branches.js @@ -0,0 +1,145 @@ +'use strict'; + +// Branch coverage for VFSWatcher / VFSStatWatcher / VFSWatchAsyncIterable. + +const common = require('../common'); +const assert = require('assert'); +const { once } = require('events'); +const vfs = require('node:vfs'); + +(async () => { + // close() while a poll is in-flight after #closed flag is set — + // close + close again is the simplest #closed-true branch. + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/x.txt', 'a'); + const watcher = myVfs.watch('/x.txt'); + watcher.close(); + watcher.close(); // second close is a no-op (#closed already true) + } + + // 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 directory and deleting a tracked file (covers the + // `file deleted` path in #pollDirectory). + { + 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(); + } + + // Watching a directory whose listing fails mid-poll: delete the + // directory itself to trigger the `try/catch { /* ignore */ }` + // around readdirSync inside #pollDirectory. + { + 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 }); + // give the poll one tick + await new Promise((r) => setTimeout(r, 60)); + watcher.close(); + } + + // VFSStatWatcher with bigint option — covers ctime/size branches and + // the bigint createZeroStats path. + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/sw.txt', 'a'); + let listener; + const fired = new Promise((resolve) => { + listener = (curr, prev) => { + assert.strictEqual(typeof curr.size, 'bigint'); + assert.strictEqual(typeof prev.size, 'bigint'); + resolve(); + }; + }); + myVfs.watchFile('/sw.txt', { interval: 25, bigint: true }, listener); + myVfs.writeFileSync('/sw.txt', 'changed!!!!'); + await fired; + myVfs.unwatchFile('/sw.txt', listener); + } + + // VFSStatWatcher default interval (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); + } + + // VFSStatWatcher: stop on already-stopped watcher is a no-op + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/sw2.txt', 'a'); + const listener = () => {}; + myVfs.watchFile('/sw2.txt', { interval: 25 }, listener); + myVfs.unwatchFile('/sw2.txt', listener); + // unwatch again + myVfs.unwatchFile('/sw2.txt', listener); + } + + // Async iterable: emit a change while a next() is outstanding (covers the + // pendingResolvers shift path) + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/q.txt', 'a'); + const iter = myVfs.promises.watch('/q.txt', { interval: 25 }); + const pending = iter.next(); + myVfs.writeFileSync('/q.txt', 'BBBBBBBB'); + const r = await pending; + if (!r.done) assert.strictEqual(r.value.eventType, 'change'); + await iter.return(); + } + + // Async iterable throw() closes the watcher and resolves with done:true + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/q2.txt', 'a'); + const iter = myVfs.promises.watch('/q2.txt', { interval: 1000 }); + const r = await iter.throw(new Error('boom')); + assert.strictEqual(r.done, true); + } + + // Async iterable: queue-fill path — keep modifying without consuming. + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/q3.txt', 'a'); + const iter = myVfs.promises.watch('/q3.txt', { interval: 25 }); + for (let i = 0; i < 5; i++) { + myVfs.writeFileSync('/q3.txt', 'x'.repeat(i + 5)); + await new Promise((r) => setTimeout(r, 30)); + } + // Drain at least one event + const r = await iter.next(); + assert.ok(r.value || r.done); + await iter.return(); + } + + // Async iterable: close while a resolver is pending — drains via the + // 'close' event handler (covers the close-event resolver-loop branch). + { + const myVfs = vfs.create(); + myVfs.writeFileSync('/q4.txt', 'a'); + const iter = myVfs.promises.watch('/q4.txt', { interval: 1000 }); + const pending = iter.next(); + // Queue iter.return() on a microtask so it runs before pending resolves + queueMicrotask(() => iter.return()); + const r = await pending; + assert.strictEqual(r.done, true); + } +})().then(common.mustCall()); From c9b5b63c11786f30353e665834603c5fd85ad340 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 3 May 2026 19:44:56 +0200 Subject: [PATCH 09/14] test: rename and split VFS test files into topic-based names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the -coverage / -branches / -misc suffixes with focused files named after the API or behaviour they exercise. Splits the larger multi-topic files into one-topic-per-file. Renames: - callbacks.js → callback-api.js - stats-defaults.js → stats-helpers.js - file-handle-base.js → virtual-file-handle.js - provider-base.js → virtual-provider.js - provider-memory.js → memory-provider.js - real-provider-async.js → real-provider-promises.js - mkdir-recursive-return.js → mkdir.js New files (split out of -coverage/-branches/-misc): - access-modes, create, link, mkdtemp, rename, symlinks, utimes, write-options - memory-file-handle, memory-provider-dynamic, memory-provider-flags - real-provider-handle, real-provider-symlinks, real-provider-watch - stream-errors, stream-explicit-fd - watch, watch-abort-signal, watch-encoding, watch-promises, watch-recursive Coverage maintained at 97.6% line / 95.2% branch / 95.3% function. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- test/parallel/test-vfs-access-modes.js | 40 ++ ...-callbacks.js => test-vfs-callback-api.js} | 0 test/parallel/test-vfs-create.js | 64 +++ test/parallel/test-vfs-dir-handle.js | 34 ++ .../parallel/test-vfs-file-handle-branches.js | 98 ---- test/parallel/test-vfs-file-handle.js | 81 ++++ test/parallel/test-vfs-link.js | 23 + test/parallel/test-vfs-memory-branches.js | 152 ------ test/parallel/test-vfs-memory-coverage.js | 353 -------------- test/parallel/test-vfs-memory-file-handle.js | 15 + .../test-vfs-memory-provider-dynamic.js | 127 +++++ .../test-vfs-memory-provider-flags.js | 41 ++ ...-memory.js => test-vfs-memory-provider.js} | 0 test/parallel/test-vfs-misc-coverage.js | 175 ------- .../test-vfs-mkdir-recursive-return.js | 17 - test/parallel/test-vfs-mkdir.js | 48 ++ test/parallel/test-vfs-mkdtemp.js | 41 ++ test/parallel/test-vfs-provider-branches.js | 69 --- .../test-vfs-readdir-symlink-recursive.js | 32 ++ test/parallel/test-vfs-real-coverage.js | 200 -------- test/parallel/test-vfs-real-provider-async.js | 123 ----- .../parallel/test-vfs-real-provider-handle.js | 120 +++++ .../test-vfs-real-provider-promises.js | 54 +++ .../test-vfs-real-provider-symlinks.js | 110 +++++ test/parallel/test-vfs-real-provider-watch.js | 39 ++ test/parallel/test-vfs-real-provider.js | 439 ++---------------- test/parallel/test-vfs-rename.js | 45 ++ test/parallel/test-vfs-stats-bigint.js | 19 + ...-defaults.js => test-vfs-stats-helpers.js} | 0 test/parallel/test-vfs-stream-errors.js | 69 +++ test/parallel/test-vfs-stream-explicit-fd.js | 56 +++ test/parallel/test-vfs-streams-coverage.js | 113 ----- test/parallel/test-vfs-streams-misc.js | 116 ----- test/parallel/test-vfs-streams.js | 89 ++++ test/parallel/test-vfs-symlinks.js | 55 +++ test/parallel/test-vfs-utimes.js | 26 ++ ...ase.js => test-vfs-virtual-file-handle.js} | 0 ...r-base.js => test-vfs-virtual-provider.js} | 0 test/parallel/test-vfs-watch-abort-signal.js | 54 +++ test/parallel/test-vfs-watch-async.js | 93 ---- test/parallel/test-vfs-watch-directory.js | 59 ++- test/parallel/test-vfs-watch-encoding.js | 20 + test/parallel/test-vfs-watch-promises.js | 65 +++ test/parallel/test-vfs-watch-recursive.js | 33 ++ test/parallel/test-vfs-watch.js | 74 +++ test/parallel/test-vfs-watcher-branches.js | 145 ------ test/parallel/test-vfs-watcher-coverage.js | 124 ----- test/parallel/test-vfs-watchfile.js | 95 +++- test/parallel/test-vfs-write-options.js | 32 ++ 49 files changed, 1669 insertions(+), 2208 deletions(-) create mode 100644 test/parallel/test-vfs-access-modes.js rename test/parallel/{test-vfs-callbacks.js => test-vfs-callback-api.js} (100%) create mode 100644 test/parallel/test-vfs-create.js delete mode 100644 test/parallel/test-vfs-file-handle-branches.js create mode 100644 test/parallel/test-vfs-link.js delete mode 100644 test/parallel/test-vfs-memory-branches.js delete mode 100644 test/parallel/test-vfs-memory-coverage.js create mode 100644 test/parallel/test-vfs-memory-file-handle.js create mode 100644 test/parallel/test-vfs-memory-provider-dynamic.js create mode 100644 test/parallel/test-vfs-memory-provider-flags.js rename test/parallel/{test-vfs-provider-memory.js => test-vfs-memory-provider.js} (100%) delete mode 100644 test/parallel/test-vfs-misc-coverage.js delete mode 100644 test/parallel/test-vfs-mkdir-recursive-return.js create mode 100644 test/parallel/test-vfs-mkdir.js create mode 100644 test/parallel/test-vfs-mkdtemp.js delete mode 100644 test/parallel/test-vfs-provider-branches.js delete mode 100644 test/parallel/test-vfs-real-coverage.js delete mode 100644 test/parallel/test-vfs-real-provider-async.js create mode 100644 test/parallel/test-vfs-real-provider-handle.js create mode 100644 test/parallel/test-vfs-real-provider-promises.js create mode 100644 test/parallel/test-vfs-real-provider-symlinks.js create mode 100644 test/parallel/test-vfs-real-provider-watch.js create mode 100644 test/parallel/test-vfs-rename.js rename test/parallel/{test-vfs-stats-defaults.js => test-vfs-stats-helpers.js} (100%) create mode 100644 test/parallel/test-vfs-stream-errors.js create mode 100644 test/parallel/test-vfs-stream-explicit-fd.js delete mode 100644 test/parallel/test-vfs-streams-coverage.js delete mode 100644 test/parallel/test-vfs-streams-misc.js create mode 100644 test/parallel/test-vfs-symlinks.js create mode 100644 test/parallel/test-vfs-utimes.js rename test/parallel/{test-vfs-file-handle-base.js => test-vfs-virtual-file-handle.js} (100%) rename test/parallel/{test-vfs-provider-base.js => test-vfs-virtual-provider.js} (100%) create mode 100644 test/parallel/test-vfs-watch-abort-signal.js delete mode 100644 test/parallel/test-vfs-watch-async.js create mode 100644 test/parallel/test-vfs-watch-encoding.js create mode 100644 test/parallel/test-vfs-watch-promises.js create mode 100644 test/parallel/test-vfs-watch-recursive.js create mode 100644 test/parallel/test-vfs-watch.js delete mode 100644 test/parallel/test-vfs-watcher-branches.js delete mode 100644 test/parallel/test-vfs-watcher-coverage.js create mode 100644 test/parallel/test-vfs-write-options.js diff --git a/test/parallel/test-vfs-access-modes.js b/test/parallel/test-vfs-access-modes.js new file mode 100644 index 00000000000000..45690efbdc37d9 --- /dev/null +++ b/test/parallel/test-vfs-access-modes.js @@ -0,0 +1,40 @@ +'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-callbacks.js b/test/parallel/test-vfs-callback-api.js similarity index 100% rename from test/parallel/test-vfs-callbacks.js rename to test/parallel/test-vfs-callback-api.js diff --git a/test/parallel/test-vfs-create.js b/test/parallel/test-vfs-create.js new file mode 100644 index 00000000000000..4b6d4f7c62ddde --- /dev/null +++ b/test/parallel/test-vfs-create.js @@ -0,0 +1,64 @@ +'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-dir-handle.js b/test/parallel/test-vfs-dir-handle.js index e78753a9e389d5..730a795c1cd195 100644 --- a/test/parallel/test-vfs-dir-handle.js +++ b/test/parallel/test-vfs-dir-handle.js @@ -71,3 +71,37 @@ myVfs.opendir('/d', common.mustCall((err, 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( + (async () => { for await (const _ of dir.entries()) {} })(), // eslint-disable-line no-unused-vars + { 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-file-handle-branches.js b/test/parallel/test-vfs-file-handle-branches.js deleted file mode 100644 index a320e4f7af9128..00000000000000 --- a/test/parallel/test-vfs-file-handle-branches.js +++ /dev/null @@ -1,98 +0,0 @@ -// Flags: --expose-internals -'use strict'; - -// Cover branch paths in MemoryFileHandle (and base VirtualFileHandle). - -const common = require('../common'); -const assert = require('assert'); -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); -myVfs.writeFileSync('/file.txt', 'hello world'); - -(async () => { - // readv with explicit position and a partial read at EOF - const handle = await myVfs.provider.open('/file.txt', 'r'); - const b1 = Buffer.alloc(5); - const b2 = Buffer.alloc(20); // larger than remaining → partial → break - const r = await handle.readv([b1, b2], 0); - assert.strictEqual(b1.toString(), 'hello'); - assert.strictEqual(r.bytesRead, 11); - await handle.close(); - - // writev with explicit position - 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'); - - // appendFile with string + encoding option - 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()); - -// #checkReadable: 'w' mode rejects reads 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()); - -// #checkWritable: 'r' mode rejects writes 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()); - -// writeFileSync with string + encoding -(async () => { - const handle = await myVfs.provider.open('/se.txt', 'w+'); - await handle.writeFile('héllo', { encoding: 'utf8' }); - const got = await handle.readFile('utf8'); - assert.strictEqual(got, 'héllo'); - await handle.close(); -})().then(common.mustCall()); - -// truncateSync extending past current size -(async () => { - const handle = await myVfs.provider.open('/grow.txt', 'w+'); - await handle.writeFile('abc'); - await handle.truncate(10); - const stats = await handle.stat(); - assert.strictEqual(stats.size, 10); - // Content has zero-filled extension - const data = await handle.readFile(); - assert.strictEqual(data.length, 10); - await handle.close(); -})().then(common.mustCall()); - -// MemoryFileHandle without a #getStats callback throws ERR_INVALID_STATE -{ - const { MemoryFileHandle } = require('internal/vfs/file_handle'); - // Pass undefined as getStats — entry is null so the dynamic-content - // branches don't trigger; statSync falls into the "stats not available" - // path. - const h = new MemoryFileHandle('/x', 'r', 0o644, Buffer.alloc(0), null, undefined); - assert.throws(() => h.statSync(), { code: 'ERR_INVALID_STATE' }); -} - -// readv on closed handle rejects 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-file-handle.js b/test/parallel/test-vfs-file-handle.js index 307a909cead4ad..27e48077ca1fdc 100644 --- a/test/parallel/test-vfs-file-handle.js +++ b/test/parallel/test-vfs-file-handle.js @@ -121,3 +121,84 @@ myVfs.writeFileSync('/file.txt', 'hello world'); { 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-link.js b/test/parallel/test-vfs-link.js new file mode 100644 index 00000000000000..c94669d153404b --- /dev/null +++ b/test/parallel/test-vfs-link.js @@ -0,0 +1,23 @@ +'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-branches.js b/test/parallel/test-vfs-memory-branches.js deleted file mode 100644 index a2ec2f248ea00b..00000000000000 --- a/test/parallel/test-vfs-memory-branches.js +++ /dev/null @@ -1,152 +0,0 @@ -// Flags: --expose-internals -'use strict'; - -// Cover branch paths in MemoryProvider that aren't reached by the -// happy-path tests. - -require('../common'); -const assert = require('assert'); -const fs = require('fs'); -const vfs = require('node:vfs'); - -// utimes with non-number / non-string / non-object → falls through to -// `return time;` in toMs. -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/u.txt', 'x'); - myVfs.utimesSync('/u.txt', null, undefined); -} - -// utimes with object Date instances -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/u2.txt', 'x'); - myVfs.utimesSync('/u2.txt', new Date(0), new Date(1)); -} - -// normalizeFlags: every numeric flag combination -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/seed.txt', 'x'); - // 'a' = append + write only (no rdwr) - myVfs.openSync('/append.txt', - fs.constants.O_APPEND | fs.constants.O_CREAT | - fs.constants.O_WRONLY).closeSync; - // 'a+' = append + rdwr - myVfs.openSync('/append-plus.txt', - fs.constants.O_APPEND | fs.constants.O_CREAT | - fs.constants.O_RDWR).closeSync; - // 'ax' = append + excl - myVfs.openSync('/ax.txt', - fs.constants.O_APPEND | fs.constants.O_EXCL | - fs.constants.O_CREAT | fs.constants.O_WRONLY).closeSync; - // 'r+' = rdwr (no write/append/excl) - myVfs.openSync('/seed.txt', fs.constants.O_RDWR).closeSync; -} - -// mkdir recursive: an intermediate path is a regular file → ENOTDIR -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/blocker', 'x'); - assert.throws( - () => myVfs.mkdirSync('/blocker/sub', { recursive: true }), - { code: 'ENOTDIR' }, - ); -} - -// mkdir with explicit mode -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/d-mode', { mode: 0o700 }); - const st = myVfs.statSync('/d-mode'); - assert.strictEqual(st.mode & 0o777, 0o700); - - myVfs.mkdirSync('/r-mode/sub/deep', { recursive: true, mode: 0o700 }); - assert.strictEqual( - myVfs.statSync('/r-mode/sub/deep').mode & 0o777, 0o700); -} - -// rename within the same parent -{ - 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); -} - -// Dynamic content provider returning a Buffer (not string) -{ - const memMod = require('internal/vfs/providers/memory'); - const { MemoryProvider } = memMod; - const provider = new MemoryProvider(); - const symbols = Object.getOwnPropertySymbols(provider); - const kRoot = symbols.find((s) => s.description === 'kRoot'); - const root = provider[kRoot]; - const memEntryProto = Object.getPrototypeOf(root); - - const fileEntry = Object.create(memEntryProto); - fileEntry.type = 0; // TYPE_FILE - fileEntry.mode = 0o644; - fileEntry.content = Buffer.alloc(0); - fileEntry.contentProvider = () => Buffer.from('buffer-content'); - fileEntry.children = null; - fileEntry.target = null; - fileEntry.populate = null; - fileEntry.populated = true; - fileEntry.nlink = 1; - fileEntry.uid = 0; - fileEntry.gid = 0; - const t = Date.now(); - fileEntry.atime = t; - fileEntry.mtime = t; - fileEntry.ctime = t; - fileEntry.birthtime = t; - fileEntry.isFile = root.isFile.bind(fileEntry); - fileEntry.isDirectory = root.isDirectory.bind(fileEntry); - fileEntry.isSymbolicLink = root.isSymbolicLink.bind(fileEntry); - fileEntry.isDynamic = root.isDynamic.bind(fileEntry); - fileEntry.getContentSync = root.getContentSync.bind(fileEntry); - fileEntry.getContentAsync = root.getContentAsync.bind(fileEntry); - root.children.set('buf-dyn.txt', fileEntry); - - const myVfs = vfs.create(provider); - assert.strictEqual(myVfs.readFileSync('/buf-dyn.txt', 'utf8'), 'buffer-content'); -} - -// resolveSymlinkTarget with absolute target (root-relative) within 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'); -} - -// Lookup root path (resolves to root via early return) -{ - const myVfs = vfs.create(); - const st = myVfs.statSync('/'); - assert.ok(st.isDirectory()); -} - -// Symlink loop in intermediate path (ELOOP) -{ - const myVfs = vfs.create(); - myVfs.symlinkSync('/loop2', '/loop1'); - myVfs.symlinkSync('/loop1', '/loop2'); - assert.throws(() => myVfs.statSync('/loop1/sub'), - { code: 'ELOOP' }); -} - -// rename with same destination throws when types differ — exercises -// the existingDest type-mismatch checks already; here cover the -// successful overwrite of a file by a file. -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/x.txt', 'old'); - myVfs.writeFileSync('/y.txt', 'new'); - myVfs.renameSync('/y.txt', '/x.txt'); - assert.strictEqual(myVfs.readFileSync('/x.txt', 'utf8'), 'new'); -} diff --git a/test/parallel/test-vfs-memory-coverage.js b/test/parallel/test-vfs-memory-coverage.js deleted file mode 100644 index cb2581901be9b8..00000000000000 --- a/test/parallel/test-vfs-memory-coverage.js +++ /dev/null @@ -1,353 +0,0 @@ -// Flags: --expose-internals -'use strict'; - -// Cover MemoryProvider edge cases that aren't reached by the standard -// public-API tests: numeric flags, symlink loops, dynamic content -// providers, and lazy-populated directories. - -const common = require('../common'); -const assert = require('assert'); -const vfs = require('node:vfs'); -const fs = require('fs'); - -// Numeric open flags (mirrors fs.constants.O_*) must be normalised to -// their string equivalents. -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/file.txt', 'orig'); - - // O_RDONLY (0) - let fd = myVfs.openSync('/file.txt', fs.constants.O_RDONLY); - myVfs.closeSync(fd); - - // O_RDWR - fd = myVfs.openSync('/file.txt', fs.constants.O_RDWR); - myVfs.closeSync(fd); - - // O_WRONLY | O_CREAT | O_TRUNC = 'w' - fd = myVfs.openSync('/created.txt', - fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC); - myVfs.closeSync(fd); - - // O_WRONLY | O_CREAT | O_EXCL = 'wx' - fd = myVfs.openSync('/excl.txt', - fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL); - myVfs.closeSync(fd); - - // 'wx' on existing file throws EEXIST - assert.throws( - () => myVfs.openSync('/file.txt', - fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL), - { code: 'EEXIST' }); - - // O_APPEND | O_RDWR | O_CREAT - fd = myVfs.openSync('/app.txt', - fs.constants.O_APPEND | fs.constants.O_RDWR | fs.constants.O_CREAT); - myVfs.closeSync(fd); - - // O_APPEND | O_EXCL | O_RDWR | O_CREAT = 'ax+' - fd = myVfs.openSync('/axplus.txt', - fs.constants.O_APPEND | fs.constants.O_EXCL | - fs.constants.O_RDWR | fs.constants.O_CREAT); - myVfs.closeSync(fd); - - // Bogus non-string non-number: defaults to 'r' - fd = myVfs.openSync('/file.txt', null); - myVfs.closeSync(fd); -} - -// utimes with numeric (seconds) and Date arguments -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/u.txt', 'x'); - // numeric seconds branch - myVfs.utimesSync('/u.txt', 1000, 2000); - // Date branch - myVfs.utimesSync('/u.txt', new Date(3000000), new Date(4000000)); -} - -// Symlink loop detection — covers createELOOP path -{ - 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' }); -} - -// Geometric buffer growth in writeSync — append many times to exercise -// the doubling path. -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/grow.txt', 'x'.repeat(10)); - for (let i = 0; i < 8; i++) { - myVfs.appendFileSync('/grow.txt', 'y'.repeat(2 ** i)); - } - assert.ok(myVfs.readFileSync('/grow.txt').length > 250); -} - -// Dynamic content providers (sync) and lazy directory population. -// These features have no public construction API, so we drive them -// directly through MemoryEntry / MemoryProvider internals. -{ - const memMod = require('internal/vfs/providers/memory'); - const { MemoryProvider } = memMod; - const provider = new MemoryProvider(); - - // Lazy-populated directory: install a populate callback on an existing - // directory entry via the internal kRoot symbol. - const symbols = Object.getOwnPropertySymbols(provider); - const kRoot = symbols.find((s) => s.description === 'kRoot'); - assert.ok(kRoot, 'kRoot symbol expected on MemoryProvider'); - const root = provider[kRoot]; - - // Manually create a lazy directory entry. - const memEntryProto = Object.getPrototypeOf(root); - const dir = Object.create(memEntryProto); - dir.type = 1; // TYPE_DIR - dir.mode = 0o755; - dir.children = null; - dir.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'); - }; - dir.populated = false; - dir.nlink = 1; - dir.uid = 0; - dir.gid = 0; - const t = Date.now(); - dir.atime = t; - dir.mtime = t; - dir.ctime = t; - dir.birthtime = t; - // Borrow methods from an existing entry - 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); - - // Need a SafeMap children for the directory to behave correctly. - dir.children = new Map(); - 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 lazy file content - assert.strictEqual(myVfs.readFileSync('/lazy/hello.txt', 'utf8'), 'lazy hello'); - - // Dynamic content provider (sync, returns string) - assert.strictEqual(myVfs.readFileSync('/lazy/dyn.txt', 'utf8'), 'dynamic-string'); - - // Dynamic content provider (async via promises) - myVfs.promises.readFile('/lazy/dyn.txt', 'utf8').then(common.mustCall((s) => { - assert.strictEqual(s, 'dynamic-string'); - })); -} - -// readdir basic — withFileTypes false returns names -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/d'); - myVfs.writeFileSync('/d/a.txt', ''); - const names = myVfs.readdirSync('/d'); - assert.deepStrictEqual(names, ['a.txt']); -} - -// rename onto an existing 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); -} - -// rmdir on non-empty directory throws ENOTEMPTY -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/d'); - myVfs.writeFileSync('/d/x', ''); - assert.throws(() => myVfs.rmdirSync('/d'), { code: 'ENOTEMPTY' }); -} - -// chown / chmod / lutimes -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/p.txt', 'x'); - myVfs.symlinkSync('/p.txt', '/lk'); - myVfs.chmodSync('/p.txt', 0o600); - myVfs.chownSync('/p.txt', 100, 200); - myVfs.lutimesSync('/lk', new Date(0), new Date(0)); - const st = myVfs.statSync('/p.txt'); - assert.strictEqual(st.uid, 100); - assert.strictEqual(st.gid, 200); -} - -// utimes with string time (treated as DateNow) -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/u2.txt', 'x'); - myVfs.utimesSync('/u2.txt', 'now', 'now'); -} - -// readdir with mixed entry types (file, dir, symlink) — exercises -// non-recursive Dirent type branches. -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/d'); - myVfs.writeFileSync('/d/a.txt', 'x'); - myVfs.mkdirSync('/d/sub'); - myVfs.symlinkSync('a.txt', '/d/lnk'); - const dirents = myVfs.readdirSync('/d', { withFileTypes: true }); - const types = dirents.map((d) => d.name + ':' + (d.isFile() ? 'f' : d.isDirectory() ? 'd' : d.isSymbolicLink() ? 'l' : '?')); - assert.ok(types.includes('a.txt:f')); - assert.ok(types.includes('sub:d')); - assert.ok(types.includes('lnk:l')); -} - -// rename: type mismatches throw -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/file.txt', 'x'); - myVfs.mkdirSync('/dir'); - // rename file onto dir → EISDIR - assert.throws(() => myVfs.renameSync('/file.txt', '/dir'), { code: 'EISDIR' }); - // rename dir onto file → ENOTDIR - assert.throws(() => myVfs.renameSync('/dir', '/file.txt'), { code: 'ENOTDIR' }); -} - -// link to a directory throws EINVAL -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/d'); - assert.throws(() => myVfs.linkSync('/d', '/d-link'), { code: 'EINVAL' }); -} - -// link to 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' }); -} - -// symlink with existing target throws EEXIST -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/a.txt', 'x'); - assert.throws(() => myVfs.symlinkSync('/a.txt', '/a.txt'), { code: 'EEXIST' }); -} - -// readonly write paths throw EROFS -{ - const provider = new vfs.MemoryProvider(); - const myVfs = vfs.create(provider); - myVfs.writeFileSync('/f.txt', 'x'); - myVfs.mkdirSync('/d'); - myVfs.symlinkSync('/f.txt', '/lnk'); - provider.setReadOnly(); - assert.throws(() => myVfs.openSync('/f.txt', 'w'), { code: 'EROFS' }); - assert.throws(() => myVfs.unlinkSync('/f.txt'), { code: 'EROFS' }); - assert.throws(() => myVfs.rmdirSync('/d'), { code: 'EROFS' }); - assert.throws(() => myVfs.renameSync('/f.txt', '/g.txt'), { code: 'EROFS' }); - assert.throws(() => myVfs.linkSync('/f.txt', '/h.txt'), { code: 'EROFS' }); - assert.throws(() => myVfs.symlinkSync('/x', '/y'), { code: 'EROFS' }); - assert.throws(() => myVfs.mkdirSync('/d2'), { code: 'EROFS' }); -} - -// open a file via a symlinked parent directory (covers the parent-symlink -// follow path in #ensureParent) -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/real-dir'); - myVfs.writeFileSync('/real-dir/file.txt', 'hello'); - myVfs.symlinkSync('/real-dir', '/link-dir'); - // Read through the symlinked directory - assert.strictEqual(myVfs.readFileSync('/link-dir/file.txt', 'utf8'), 'hello'); - // Write through the symlinked directory - myVfs.writeFileSync('/link-dir/new.txt', 'new'); - assert.strictEqual(myVfs.readFileSync('/real-dir/new.txt', 'utf8'), 'new'); -} - -// ENOTDIR mid-path: writing through a non-directory parent fails ENOTDIR -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/file.txt', 'x'); - // ensureParent walks the path and hits a file in the middle → ENOTDIR - assert.throws(() => myVfs.writeFileSync('/file.txt/oops', 'y'), - { code: 'ENOTDIR' }); -} - -// Dynamic content provider returning a Promise — sync API throws -{ - const memMod = require('internal/vfs/providers/memory'); - const { MemoryProvider } = memMod; - const provider = new MemoryProvider(); - const symbols = Object.getOwnPropertySymbols(provider); - const kRoot = symbols.find((s) => s.description === 'kRoot'); - const root = provider[kRoot]; - - // Create a file with an async content provider - const memEntryProto = Object.getPrototypeOf(root); - const fileEntry = Object.create(memEntryProto); - fileEntry.type = 0; // TYPE_FILE - fileEntry.mode = 0o644; - fileEntry.content = Buffer.alloc(0); - fileEntry.contentProvider = async () => 'async-only'; - fileEntry.children = null; - fileEntry.target = null; - fileEntry.populate = null; - fileEntry.populated = true; - fileEntry.nlink = 1; - fileEntry.uid = 0; - fileEntry.gid = 0; - const t = Date.now(); - fileEntry.atime = t; - fileEntry.mtime = t; - fileEntry.ctime = t; - fileEntry.birthtime = t; - fileEntry.isFile = root.isFile.bind(fileEntry); - fileEntry.isDirectory = root.isDirectory.bind(fileEntry); - fileEntry.isSymbolicLink = root.isSymbolicLink.bind(fileEntry); - fileEntry.isDynamic = root.isDynamic.bind(fileEntry); - fileEntry.getContentSync = root.getContentSync.bind(fileEntry); - fileEntry.getContentAsync = root.getContentAsync.bind(fileEntry); - - root.children.set('async-only.txt', fileEntry); - - const myVfs = vfs.create(provider); - // Sync read with async provider throws ERR_INVALID_STATE - assert.throws(() => myVfs.readFileSync('/async-only.txt'), - { code: 'ERR_INVALID_STATE' }); - // Async read works - myVfs.promises.readFile('/async-only.txt', 'utf8').then(common.mustCall((s) => { - assert.strictEqual(s, 'async-only'); - })); -} - -// MemoryProvider basic watch + watchAsync + watchFile -{ - const provider = new vfs.MemoryProvider(); - assert.strictEqual(provider.supportsWatch, true); - const myVfs = vfs.create(provider); - myVfs.writeFileSync('/wf.txt', 'a'); - - const w = myVfs.watch('/wf.txt'); - w.close(); - - const ai = myVfs.promises.watch('/wf.txt'); - ai.return().then(common.mustCall()); - - const listener = () => {}; - myVfs.watchFile('/wf.txt', { interval: 1000, persistent: false }, listener); - myVfs.unwatchFile('/wf.txt', listener); -} 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..fef915ad505adc --- /dev/null +++ b/test/parallel/test-vfs-memory-file-handle.js @@ -0,0 +1,15 @@ +// Flags: --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..0a8f391a2c46a1 --- /dev/null +++ b/test/parallel/test-vfs-memory-provider-dynamic.js @@ -0,0 +1,127 @@ +// Flags: --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 = Object.create(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 = Object.create(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..9a10cba494328a --- /dev/null +++ b/test/parallel/test-vfs-memory-provider-flags.js @@ -0,0 +1,41 @@ +'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-provider-memory.js b/test/parallel/test-vfs-memory-provider.js similarity index 100% rename from test/parallel/test-vfs-provider-memory.js rename to test/parallel/test-vfs-memory-provider.js diff --git a/test/parallel/test-vfs-misc-coverage.js b/test/parallel/test-vfs-misc-coverage.js deleted file mode 100644 index 5e4256875c657f..00000000000000 --- a/test/parallel/test-vfs-misc-coverage.js +++ /dev/null @@ -1,175 +0,0 @@ -'use strict'; - -// Cover small uncovered branches across the VFS subsystem. - -const common = require('../common'); -const assert = require('assert'); -const vfs = require('node:vfs'); - -// vfs.create with first arg as options (not a provider, no openSync method) -{ - const myVfs = vfs.create({ emitExperimentalWarning: false }); - assert.ok(myVfs); - assert.ok(myVfs.provider instanceof vfs.MemoryProvider); -} - -// new VirtualFileSystem(options) directly -{ - const myVfs = new vfs.VirtualFileSystem({ emitExperimentalWarning: false }); - assert.ok(myVfs); - // emitExperimentalWarning option is validated as boolean - assert.throws(() => - new vfs.VirtualFileSystem({ emitExperimentalWarning: 'not-bool' }), - { code: 'ERR_INVALID_ARG_TYPE' }); -} - -// existsSync swallows path errors and returns false -{ - const myVfs = vfs.create(); - assert.strictEqual(myVfs.existsSync('/nope'), false); -} - -// readdir({ withFileTypes: true, recursive: true }) — covers the recursive -// dirent path that fixes parentPath when names contain slashes. -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/r/a/b', { recursive: true }); - myVfs.writeFileSync('/r/top.txt', 'x'); - myVfs.writeFileSync('/r/a/b/leaf.txt', 'y'); - - const dirents = myVfs.readdirSync('/r', { withFileTypes: true, recursive: true }); - // Find the leaf in the recursive listing - const leaf = dirents.find((d) => d.name === 'leaf.txt'); - assert.ok(leaf, 'leaf entry expected'); - assert.strictEqual(leaf.parentPath, '/r/a/b'); - - // Top-level entry has parentPath = root - const top = dirents.find((d) => d.name === 'top.txt'); - assert.ok(top); - assert.strictEqual(top.parentPath, '/r'); -} - -// stats bigint paths for directories, symlinks, and zero-stats -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/dir'); - myVfs.symlinkSync('/dir', '/link'); - myVfs.writeFileSync('/file.txt', 'x'); - - const dirStat = myVfs.statSync('/dir', { bigint: true }); - assert.strictEqual(typeof dirStat.size, 'bigint'); - assert.strictEqual(dirStat.isDirectory(), true); - - const linkStat = myVfs.lstatSync('/link', { bigint: true }); - assert.strictEqual(typeof linkStat.size, 'bigint'); - assert.strictEqual(linkStat.isSymbolicLink(), true); -} - -// watchFile on a missing file should emit zero-stats (covers createZeroStats). -// The initial poll establishes prev as zero-stats; once the file is created, -// the listener sees prev with size 0n. -{ - const myVfs = vfs.create(); - const watcher = myVfs.watchFile('/missing.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.txt'); - }, 1)); - setTimeout(() => myVfs.writeFileSync('/missing.txt', 'now-here'), 80); - setTimeout(() => myVfs.unwatchFile('/missing.txt'), 500); - if (watcher && watcher.unref) watcher.unref(); -} - -// VirtualDir read callback error path: pre-closed dir -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/d'); - const dir = myVfs.opendirSync('/d'); - dir.closeSync(); - dir.read(common.mustCall((err) => { - assert.strictEqual(err.code, 'ERR_DIR_CLOSED'); - })); - // entries() iterator on a closed dir throws when iterated - (async () => { - await assert.rejects( - (async () => { for await (const _ of dir.entries()) {} })(), // eslint-disable-line no-unused-vars - { 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 myVfs = vfs.create(); - myVfs.mkdirSync('/d2'); - const dir = myVfs.opendirSync('/d2'); - await dir.close(); -})().then(common.mustCall()); - -// createReadStream path getter coverage already in streams-misc; here we -// destroy the stream early to cover _destroy + _close paths. -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/x.txt', 'data'); - const rs = myVfs.createReadStream('/x.txt'); - rs.on('error', () => {}); - rs.destroy(); -} - -// MemoryProvider setReadOnly — once read-only, writes throw EROFS -{ - const provider = new vfs.MemoryProvider(); - const myVfs = vfs.create(provider); - myVfs.writeFileSync('/a.txt', 'x'); - provider.setReadOnly(); - assert.strictEqual(provider.readonly, true); - assert.throws(() => myVfs.writeFileSync('/a.txt', 'y'), { code: 'EROFS' }); -} - -// existsSync swallows ALL errors from the provider, not just ENOENT -{ - // Use a custom provider whose existsSync throws - class ThrowingProvider extends vfs.VirtualProvider { - existsSync() { throw new Error('boom'); } - } - const myVfs = vfs.create(new ThrowingProvider()); - assert.strictEqual(myVfs.existsSync('/anything'), false); -} - -// opendirSync without options object (covers the `options?.recursive` undefined branch) -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/od'); - myVfs.writeFileSync('/od/a.txt', ''); - const dir = myVfs.opendirSync('/od'); - dir.closeSync(); -} - -// mkdtemp callback failure path (mkdtempSync throws because parent is missing) -{ - const myVfs = vfs.create(); - myVfs.mkdtemp('/missing/prefix-', common.mustCall((err) => { - assert.ok(err); - })); -} - -// watch with listener as 2nd argument -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/lf.txt', 'a'); - const w = myVfs.watch('/lf.txt', () => {}); - w.close(); -} - -// watchFile with listener as 2nd argument -{ - const myVfs = vfs.create(); - myVfs.writeFileSync('/lf2.txt', 'a'); - const listener = () => {}; - myVfs.watchFile('/lf2.txt', listener); - myVfs.unwatchFile('/lf2.txt', listener); -} diff --git a/test/parallel/test-vfs-mkdir-recursive-return.js b/test/parallel/test-vfs-mkdir-recursive-return.js deleted file mode 100644 index e3bd6b7c5e9309..00000000000000 --- a/test/parallel/test-vfs-mkdir-recursive-return.js +++ /dev/null @@ -1,17 +0,0 @@ -'use strict'; - -// Verify mkdirSync({ recursive: true }) returns the first directory created. -// When some parent directories already exist, the return value should be the -// first newly-created directory path. - -require('../common'); -const assert = require('assert'); -const vfs = require('node:vfs'); - -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/a'); - - const result = myVfs.mkdirSync('/a/b/c', { recursive: true }); - assert.strictEqual(result, '/a/b'); -} diff --git a/test/parallel/test-vfs-mkdir.js b/test/parallel/test-vfs-mkdir.js new file mode 100644 index 00000000000000..9718abe9cca118 --- /dev/null +++ b/test/parallel/test-vfs-mkdir.js @@ -0,0 +1,48 @@ +'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..fbe5e048c5e1f9 --- /dev/null +++ b/test/parallel/test-vfs-mkdtemp.js @@ -0,0 +1,41 @@ +'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.mustCall((err, dir) => { + assert.ifError(err); + assert.ok(dir.startsWith('/tmp-')); + })); +} + +// mkdtemp callback variant — with options object +{ + const myVfs = vfs.create(); + myVfs.mkdtemp('/tmp-', {}, common.mustCall((err, dir) => { + assert.ifError(err); + 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-provider-branches.js b/test/parallel/test-vfs-provider-branches.js deleted file mode 100644 index 27cd1ee44ab97e..00000000000000 --- a/test/parallel/test-vfs-provider-branches.js +++ /dev/null @@ -1,69 +0,0 @@ -'use strict'; - -// Cover branch paths in provider.js — explicit options.flag / options.mode -// for writeFile/appendFile, and the access-mode permission denials. - -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; - -// writeFile / writeFileSync 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'); - - // promises path - myVfs.promises.writeFile('/b.txt', 'world', { flag: 'w', mode: 0o600 }) - .then(common.mustCall(() => { - assert.strictEqual(myVfs.readFileSync('/b.txt', 'utf8'), 'world'); - })); -} - -// appendFile / appendFileSync 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'); - })); -} - -// access permission denials — chmod the file to a permission-restricted mode -// so that R_OK / W_OK / X_OK each trigger EACCES via #checkAccessMode. -{ - 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) — existence-only check, no permission needed - myVfs.accessSync('/no-r.txt', 0); - // mode passed as null also exits early - myVfs.accessSync('/no-r.txt', null); -} diff --git a/test/parallel/test-vfs-readdir-symlink-recursive.js b/test/parallel/test-vfs-readdir-symlink-recursive.js index 31f44b8ebd1e2d..3488f04fefa94d 100644 --- a/test/parallel/test-vfs-readdir-symlink-recursive.js +++ b/test/parallel/test-vfs-readdir-symlink-recursive.js @@ -19,3 +19,35 @@ 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-real-coverage.js b/test/parallel/test-vfs-real-coverage.js deleted file mode 100644 index 2ca0c0b5734fb9..00000000000000 --- a/test/parallel/test-vfs-real-coverage.js +++ /dev/null @@ -1,200 +0,0 @@ -'use strict'; - -// Cover RealFSProvider edge cases: path-escape rejection, RealFileHandle -// methods, error paths. Run sequentially to avoid fd-recycling races -// between independent (async () => {})() blocks. - -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-coverage'); -fs.mkdirSync(root, { recursive: true }); - -const myVfs = vfs.create(new vfs.RealFSProvider(root)); - -(async () => { - // RealFileHandle methods after close throw EBADF - { - 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' }); - handle.closeSync(); - await handle.close(); - } - - // RealFileHandle read/write/stat/truncate happy path - { - 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); - const r = handle.readSync(buf, 0, 3, 0); - assert.strictEqual(r, 3); - assert.strictEqual(buf.toString(), 'abc'); - - const r2 = await handle.read(Buffer.alloc(3), 0, 3, 3); - assert.strictEqual(r2.bytesRead, 3); - assert.strictEqual(r2.buffer.toString(), 'def'); - - const wbuf = Buffer.from('zz'); - handle.writeSync(wbuf, 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(); - } - - // Path-escape rejection - { - await assert.rejects(myVfs.promises.stat('/../../../etc/passwd'), - { code: 'ENOENT' }); - - fs.writeFileSync(path.join(tmpdir.path, 'outside.txt'), 'forbidden'); - fs.symlinkSync(path.join(tmpdir.path, 'outside.txt'), - path.join(root, 'esc-link')); - - const target = myVfs.readlinkSync('/esc-link'); - assert.strictEqual(target, path.join(tmpdir.path, 'outside.txt')); - const target2 = await myVfs.promises.readlink('/esc-link'); - assert.strictEqual(target2, path.join(tmpdir.path, 'outside.txt')); - - assert.throws(() => myVfs.realpathSync('/esc-link'), - { code: 'EACCES' }); - await assert.rejects(myVfs.promises.realpath('/esc-link'), - { code: 'EACCES' }); - } - - // Relative-target symlink within root - { - 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'); - } - - // access existing/missing - { - await myVfs.promises.writeFile('/acc.txt', 'x'); - myVfs.accessSync('/acc.txt'); - await myVfs.promises.access('/acc.txt'); - await assert.rejects(myVfs.promises.access('/missing.txt'), - { code: 'ENOENT' }); - } - - // open async error - { - await assert.rejects(myVfs.provider.open('/missing.txt', 'r'), - { code: 'ENOENT' }); - } - - // RealFileHandle 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' }); - } - - // Symlink with relative target outside root → EACCES - { - assert.throws(() => - myVfs.symlinkSync('../../escape', '/bad-link'), - { code: 'EACCES' }); - await assert.rejects( - myVfs.promises.symlink('../../escape', '/bad-link2'), - { code: 'EACCES' }, - ); - } - - // realpath via second escape-link - { - fs.writeFileSync(path.join(tmpdir.path, 'outside2.txt'), 'forbid'); - fs.symlinkSync(path.join(tmpdir.path, 'outside2.txt'), - path.join(root, 'esc-link2')); - assert.throws(() => myVfs.realpathSync('/esc-link2'), - { code: 'EACCES' }); - await assert.rejects(myVfs.promises.realpath('/esc-link2'), - { code: 'EACCES' }); - } - - // Symlink whose absolute target equals root → readlink returns '/' - { - fs.symlinkSync(root, path.join(root, 'root-link2')); - assert.strictEqual(myVfs.readlinkSync('/root-link2'), '/'); - } - - // VFS path with leading-..-and-no-slash escapes via path.resolve - // (covers the post-resolve security check that rejects with ENOENT). - // Note: '/../etc' normalizes back to '/etc' under root via slice(1) + - // path.resolve, so it stays inside root. To trigger the escape branch - // we use a path that does NOT start with '/' so slice(1) leaves the - // '..' intact. - { - const escapeProvider = new vfs.RealFSProvider(root); - assert.throws(() => escapeProvider.statSync('../etc/passwd'), - { code: 'ENOENT' }); - } - - // RealFSProvider with a rootPath that ends in path.sep — exercises the - // `endsWith(path.sep) ? rootPath : rootPath + sep` branch. - { - const trailingRoot = root + path.sep; - fs.writeFileSync(path.join(root, 'tr.txt'), 'tr'); - const tProvider = new vfs.RealFSProvider(trailingRoot); - assert.strictEqual(tProvider.readFileSync('/tr.txt', 'utf8'), 'tr'); - } -})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-real-provider-async.js b/test/parallel/test-vfs-real-provider-async.js deleted file mode 100644 index 767a8a1fc777b3..00000000000000 --- a/test/parallel/test-vfs-real-provider-async.js +++ /dev/null @@ -1,123 +0,0 @@ -'use strict'; - -// Cover RealFSProvider async methods, symlinks, watch, and edge cases. - -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-async'); -fs.mkdirSync(root, { recursive: true }); - -const myVfs = vfs.create(new vfs.RealFSProvider(root)); - -(async () => { - // writeFile + readFile (async) - await myVfs.promises.writeFile('/a.txt', 'hello'); - assert.strictEqual(await myVfs.promises.readFile('/a.txt', 'utf8'), 'hello'); - - // stat / lstat / access async - const st = await myVfs.promises.stat('/a.txt'); - assert.strictEqual(st.size, 5); - await myVfs.promises.access('/a.txt'); - - // mkdir / readdir / rmdir async - 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 async - 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 async - await myVfs.promises.unlink('/new.txt'); - assert.strictEqual(myVfs.existsSync('/new.txt'), false); - - // copyFile async - await myVfs.promises.copyFile('/a.txt', '/copy.txt'); - assert.strictEqual(await myVfs.promises.readFile('/copy.txt', 'utf8'), 'hello'); - - // realpath / readlink async (with relative target staying in root) - await myVfs.promises.symlink('a.txt', '/link'); - assert.strictEqual(await myVfs.promises.readlink('/link'), 'a.txt'); - assert.strictEqual(await myVfs.promises.realpath('/link'), '/a.txt'); - // realpath on root - assert.strictEqual(myVfs.realpathSync('/'), '/'); -})().then(common.mustCall()); - -// Symlinks: absolute target rejected with EACCES -{ - assert.throws( - () => myVfs.symlinkSync('/etc/passwd', '/escape'), - { code: 'EACCES' }, - ); -} - -// promises.symlink with absolute target also rejected -(async () => { - await assert.rejects( - myVfs.promises.symlink('/etc/passwd', '/escape2'), - { code: 'EACCES' }, - ); -})().then(common.mustCall()); - -// readlinkSync on a symlink whose target is inside root → translated to VFS '/'-rooted path -{ - // First put a file at root - fs.writeFileSync(path.join(root, 'target.txt'), 'x'); - // Make a symlink whose absolute target is inside root via real fs - fs.symlinkSync(path.join(root, 'target.txt'), path.join(root, 'abs-link')); - const target = myVfs.readlinkSync('/abs-link'); - // Should translate to '/target.txt' (VFS-relative) - assert.strictEqual(target, '/target.txt'); -} - -// readlinkSync where target == root → '/' -{ - fs.symlinkSync(root, path.join(root, 'root-link')); - assert.strictEqual(myVfs.readlinkSync('/root-link'), '/'); - myVfs.promises.readlink('/root-link').then(common.mustCall( - (t) => assert.strictEqual(t, '/'), - )); -} - -// realpathSync on root subdir -{ - fs.mkdirSync(path.join(root, 'sub2'), { recursive: true }); - assert.strictEqual(myVfs.realpathSync('/sub2'), '/sub2'); - myVfs.promises.realpath('/sub2').then(common.mustCall( - (p) => assert.strictEqual(p, '/sub2'), - )); -} - -// Watch capability and method calls (real fs) -{ - assert.strictEqual(myVfs.provider.supportsWatch, true); - fs.writeFileSync(path.join(root, 'watch-me.txt'), 'a'); - - const watcher = myVfs.watch('/watch-me.txt', { persistent: false }); - watcher.close(); -} - -// promises.watch returns an async iterable (we just call .return() to close it) -(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-handle.js b/test/parallel/test-vfs-real-provider-handle.js new file mode 100644 index 00000000000000..bfb07e7fb05547 --- /dev/null +++ b/test/parallel/test-vfs-real-provider-handle.js @@ -0,0 +1,120 @@ +// Flags: --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..6cf0433ae98f5b --- /dev/null +++ b/test/parallel/test-vfs-real-provider-promises.js @@ -0,0 +1,54 @@ +'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..0e5ca3f8daebd7 --- /dev/null +++ b/test/parallel/test-vfs-real-provider-symlinks.js @@ -0,0 +1,110 @@ +'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..be26d21d8d8670 --- /dev/null +++ b/test/parallel/test-vfs-real-provider-watch.js @@ -0,0 +1,39 @@ +'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 index 71e3b729ef34ca..5edda710182215 100644 --- a/test/parallel/test-vfs-real-provider.js +++ b/test/parallel/test-vfs-real-provider.js @@ -1,7 +1,12 @@ -// Flags: --expose-internals 'use strict'; -const common = require('../common'); +// 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'); @@ -13,82 +18,62 @@ tmpdir.refresh(); const testDir = path.join(tmpdir.path, 'vfs-real-provider'); fs.mkdirSync(testDir, { recursive: true }); -// Test basic RealFSProvider creation +// 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); } -// Test invalid rootPath +// Invalid rootPath { - assert.throws(() => { - new vfs.RealFSProvider(''); - }, { code: 'ERR_INVALID_ARG_VALUE' }); - - assert.throws(() => { - new vfs.RealFSProvider(123); - }, { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws(() => new vfs.RealFSProvider(''), + { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws(() => new vfs.RealFSProvider(123), + { code: 'ERR_INVALID_ARG_VALUE' }); } -// Test creating VFS with RealFSProvider +// vfs.create(provider) wires it up { const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); assert.ok(realVfs); assert.strictEqual(realVfs.readonly, false); } -// Test reading and writing files through RealFSProvider +// readFile / writeFile sync { const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - // Write a file through VFS realVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); - - // Verify it exists on the real file system const realPath = path.join(testDir, 'hello.txt'); assert.strictEqual(fs.existsSync(realPath), true); assert.strictEqual(fs.readFileSync(realPath, 'utf8'), 'Hello from VFS!'); - - // Read it back through VFS assert.strictEqual(realVfs.readFileSync('/hello.txt', 'utf8'), 'Hello from VFS!'); - - // Clean up fs.unlinkSync(realPath); } -// Test stat operations +// statSync / lstatSync { const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - // Create a file and directory fs.writeFileSync(path.join(testDir, 'stat-test.txt'), 'content'); fs.mkdirSync(path.join(testDir, 'stat-dir'), { recursive: true }); - const fileStat = realVfs.statSync('/stat-test.txt'); - assert.strictEqual(fileStat.isFile(), true); - assert.strictEqual(fileStat.isDirectory(), false); - - const dirStat = realVfs.statSync('/stat-dir'); - assert.strictEqual(dirStat.isFile(), false); - assert.strictEqual(dirStat.isDirectory(), 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); - // Test ENOENT - assert.throws(() => { - realVfs.statSync('/nonexistent'); - }, { code: 'ENOENT' }); + assert.throws(() => realVfs.statSync('/nonexistent'), + { code: 'ENOENT' }); - // Clean up fs.unlinkSync(path.join(testDir, 'stat-test.txt')); fs.rmdirSync(path.join(testDir, 'stat-dir')); } -// Test readdirSync +// 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'); @@ -96,409 +81,67 @@ fs.mkdirSync(testDir, { recursive: true }); const entries = realVfs.readdirSync('/readdir-test'); assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt', 'subdir']); - // With file types 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())); - 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); - - // Clean up 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')); } -// Test mkdir and rmdir +// 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); - assert.strictEqual(fs.statSync(path.join(testDir, 'new-dir')).isDirectory(), 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')); } -// Test unlink +// 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); } -// Test rename +// 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.existsSync(path.join(testDir, 'new-name.txt')), true); - assert.strictEqual(fs.readFileSync(path.join(testDir, 'new-name.txt'), 'utf8'), 'rename me'); - - // Clean up + assert.strictEqual(fs.readFileSync(path.join(testDir, 'new-name.txt'), 'utf8'), + 'rename me'); fs.unlinkSync(path.join(testDir, 'new-name.txt')); } -// Test path traversal prevention -{ - const subDir = path.join(testDir, 'sandbox'); - fs.mkdirSync(subDir, { recursive: true }); - - const realVfs = vfs.create(new vfs.RealFSProvider(subDir)); - - // Trying to access parent via .. should fail - assert.throws(() => { - realVfs.statSync('/../hello.txt'); - }, { code: 'ENOENT' }); - - assert.throws(() => { - realVfs.readFileSync('/../../../etc/passwd'); - }, { code: 'ENOENT' }); - - // Clean up - fs.rmdirSync(subDir); -} - -// Test async operations -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - await realVfs.promises.writeFile('/async-test.txt', 'async content'); - const content = await realVfs.promises.readFile('/async-test.txt', 'utf8'); - assert.strictEqual(content, 'async content'); - - const stat = await realVfs.promises.stat('/async-test.txt'); - assert.strictEqual(stat.isFile(), true); - - await realVfs.promises.unlink('/async-test.txt'); - assert.strictEqual(fs.existsSync(path.join(testDir, 'async-test.txt')), false); -})().then(common.mustCall()); - -// Test copyFile +// 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.existsSync(path.join(testDir, 'dest.txt')), true); - assert.strictEqual(fs.readFileSync(path.join(testDir, 'dest.txt'), 'utf8'), 'copy me'); - - // Clean up + 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')); } -// Test realpathSync +// realpathSync (non-symlink) { const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - fs.writeFileSync(path.join(testDir, 'real.txt'), 'content'); - - const resolved = realVfs.realpathSync('/real.txt'); - assert.strictEqual(resolved, '/real.txt'); - - // Clean up + assert.strictEqual(realVfs.realpathSync('/real.txt'), '/real.txt'); fs.unlinkSync(path.join(testDir, 'real.txt')); } - -// Test file handle operations via openSync -{ - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'handle-test.txt'), 'hello world'); - - const fd = realVfs.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'); - - realVfs.closeSync(fd); - - // Clean up - fs.unlinkSync(path.join(testDir, 'handle-test.txt')); -} - -// Test file handle write operations -{ - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - const fd = realVfs.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); - - realVfs.closeSync(fd); - - // Verify content - assert.strictEqual(fs.readFileSync(path.join(testDir, 'write-handle.txt'), 'utf8'), 'written via handle'); - - // Clean up - fs.unlinkSync(path.join(testDir, 'write-handle.txt')); -} - -// Test async file handle read -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'async-handle.txt'), 'async read test'); - - const fd = realVfs.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'); - - realVfs.closeSync(fd); - - // Clean up - fs.unlinkSync(path.join(testDir, 'async-handle.txt')); -})().then(common.mustCall()); - -// Test async file handle write -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - const fd = realVfs.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); - - realVfs.closeSync(fd); - - // Verify content - assert.strictEqual(fs.readFileSync(path.join(testDir, 'async-write.txt'), 'utf8'), 'async write'); - - // Clean up - fs.unlinkSync(path.join(testDir, 'async-write.txt')); -})().then(common.mustCall()); - -// Test async file handle stat -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'stat-handle.txt'), 'stat test'); - - const fd = realVfs.openSync('/stat-handle.txt', 'r'); - const handle = require('internal/vfs/fd').getVirtualFd(fd); - - const stat = await handle.entry.stat(); - assert.strictEqual(stat.isFile(), true); - - realVfs.closeSync(fd); - - // Clean up - fs.unlinkSync(path.join(testDir, 'stat-handle.txt')); -})().then(common.mustCall()); - -// Test async file handle truncate -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'truncate-handle.txt'), 'truncate this'); - - const fd = realVfs.openSync('/truncate-handle.txt', 'r+'); - const handle = require('internal/vfs/fd').getVirtualFd(fd); - - await handle.entry.truncate(8); - realVfs.closeSync(fd); - - // Verify content was truncated - assert.strictEqual(fs.readFileSync(path.join(testDir, 'truncate-handle.txt'), 'utf8'), 'truncate'); - - // Clean up - fs.unlinkSync(path.join(testDir, 'truncate-handle.txt')); -})().then(common.mustCall()); - -// Test async file handle close -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'close-handle.txt'), 'close test'); - - const fd = realVfs.openSync('/close-handle.txt', 'r'); - const handle = require('internal/vfs/fd').getVirtualFd(fd); - - await handle.entry.close(); - assert.strictEqual(handle.entry.closed, true); - - // Clean up - fs.unlinkSync(path.join(testDir, 'close-handle.txt')); -})().then(common.mustCall()); - -// Test recursive mkdir -{ - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - realVfs.mkdirSync('/deep/nested/dir', { recursive: true }); - assert.strictEqual(fs.existsSync(path.join(testDir, 'deep/nested/dir')), true); - - // Clean up - fs.rmdirSync(path.join(testDir, 'deep/nested/dir')); - fs.rmdirSync(path.join(testDir, 'deep/nested')); - fs.rmdirSync(path.join(testDir, 'deep')); -} - -// Test lstatSync -{ - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'lstat.txt'), 'lstat test'); - - const stat = realVfs.lstatSync('/lstat.txt'); - assert.strictEqual(stat.isFile(), true); - - // Clean up - fs.unlinkSync(path.join(testDir, 'lstat.txt')); -} - -// Test async lstat -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'async-lstat.txt'), 'async lstat'); - - const stat = await realVfs.promises.lstat('/async-lstat.txt'); - assert.strictEqual(stat.isFile(), true); - - // Clean up - fs.unlinkSync(path.join(testDir, 'async-lstat.txt')); -})().then(common.mustCall()); - -// Test async copyFile -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'async-src.txt'), 'async copy'); - - await realVfs.promises.copyFile('/async-src.txt', '/async-dest.txt'); - - assert.strictEqual(fs.existsSync(path.join(testDir, 'async-dest.txt')), true); - assert.strictEqual(fs.readFileSync(path.join(testDir, 'async-dest.txt'), 'utf8'), 'async copy'); - - // Clean up - fs.unlinkSync(path.join(testDir, 'async-src.txt')); - fs.unlinkSync(path.join(testDir, 'async-dest.txt')); -})().then(common.mustCall()); - -// Test async mkdir and rmdir -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - await realVfs.promises.mkdir('/async-dir'); - assert.strictEqual(fs.existsSync(path.join(testDir, 'async-dir')), true); - - await realVfs.promises.rmdir('/async-dir'); - assert.strictEqual(fs.existsSync(path.join(testDir, 'async-dir')), false); -})().then(common.mustCall()); - -// Test async rename -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'async-old.txt'), 'async rename'); - - await realVfs.promises.rename('/async-old.txt', '/async-new.txt'); - - assert.strictEqual(fs.existsSync(path.join(testDir, 'async-old.txt')), false); - assert.strictEqual(fs.existsSync(path.join(testDir, 'async-new.txt')), true); - - // Clean up - fs.unlinkSync(path.join(testDir, 'async-new.txt')); -})().then(common.mustCall()); - -// Test async readdir -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.mkdirSync(path.join(testDir, 'async-readdir'), { recursive: true }); - fs.writeFileSync(path.join(testDir, 'async-readdir', 'file.txt'), 'content'); - - const entries = await realVfs.promises.readdir('/async-readdir'); - assert.deepStrictEqual(entries, ['file.txt']); - - // Clean up - fs.unlinkSync(path.join(testDir, 'async-readdir', 'file.txt')); - fs.rmdirSync(path.join(testDir, 'async-readdir')); -})().then(common.mustCall()); - -// Test async unlink -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'async-unlink.txt'), 'to delete'); - - await realVfs.promises.unlink('/async-unlink.txt'); - assert.strictEqual(fs.existsSync(path.join(testDir, 'async-unlink.txt')), false); -})().then(common.mustCall()); - -// Test file handle readFile and writeFile -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'handle-rw.txt'), 'original'); - - const fd = realVfs.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'); - realVfs.closeSync(fd); - - assert.strictEqual(fs.readFileSync(path.join(testDir, 'handle-rw.txt'), 'utf8'), 'replaced'); - - // Clean up - fs.unlinkSync(path.join(testDir, 'handle-rw.txt')); -})().then(common.mustCall()); - -// Test async readFile and writeFile on handle -(async () => { - const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); - - fs.writeFileSync(path.join(testDir, 'async-rw.txt'), 'async original'); - - const fd = realVfs.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'); - realVfs.closeSync(fd); - - assert.strictEqual(fs.readFileSync(path.join(testDir, 'async-rw.txt'), 'utf8'), 'async replaced'); - - // Clean up - fs.unlinkSync(path.join(testDir, 'async-rw.txt')); -})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-rename.js b/test/parallel/test-vfs-rename.js new file mode 100644 index 00000000000000..97c80eb99d6ada --- /dev/null +++ b/test/parallel/test-vfs-rename.js @@ -0,0 +1,45 @@ +'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-stats-bigint.js b/test/parallel/test-vfs-stats-bigint.js index 1c42eaa0bbf165..9caaae74a9ddca 100644 --- a/test/parallel/test-vfs-stats-bigint.js +++ b/test/parallel/test-vfs-stats-bigint.js @@ -14,3 +14,22 @@ 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-defaults.js b/test/parallel/test-vfs-stats-helpers.js similarity index 100% rename from test/parallel/test-vfs-stats-defaults.js rename to test/parallel/test-vfs-stats-helpers.js diff --git a/test/parallel/test-vfs-stream-errors.js b/test/parallel/test-vfs-stream-errors.js new file mode 100644 index 00000000000000..f50f3b3a7243d1 --- /dev/null +++ b/test/parallel/test-vfs-stream-errors.js @@ -0,0 +1,69 @@ +'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..b96c7b33fcd8bf --- /dev/null +++ b/test/parallel/test-vfs-stream-explicit-fd.js @@ -0,0 +1,56 @@ +'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-streams-coverage.js b/test/parallel/test-vfs-streams-coverage.js deleted file mode 100644 index 221468b0a2bdf9..00000000000000 --- a/test/parallel/test-vfs-streams-coverage.js +++ /dev/null @@ -1,113 +0,0 @@ -'use strict'; - -// Cover stream paths not exercised by other tests: -// - write/read on destroyed/closed streams (EBADF) -// - empty file read (push(null) early path) -// - WriteStream with explicit fd + start position -// - close() error swallowed - -const common = require('../common'); -const assert = require('assert'); -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Empty file → ReadStream pushes null on the first read (remaining <= 0) -{ - myVfs.writeFileSync('/empty.txt', ''); - const rs = myVfs.createReadStream('/empty.txt'); - rs.on('data', () => assert.fail('no data expected')); - rs.on('end', common.mustCall()); -} - -// 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 }); - // Close the fd before the stream's nextTick 'open' event runs. - // The first _read will see the now-invalid fd in the lazy load path. - myVfs.closeSync(fd); - rs.on('error', common.mustCall((err) => { - assert.strictEqual(err.code, 'EBADF'); - })); - rs.resume(); // trigger _read -} - -// 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()); - -// WriteStream synchronously failing to open → destroys on next tick -{ - // openSync on /missing-dir/file.txt without recursive parents fails ENOENT - const ws = myVfs.createWriteStream('/missing-dir/foo.txt', { flags: 'wx' }); - ws.on('error', common.mustCall((err) => { - assert.ok(err); - })); -} - -// _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'); -} - -// Read stream where the lazy read (vfd.entry.readFileSync) throws. -// Externally close the underlying virtual fd before _read runs but AFTER -// the constructor has stashed it, so vfd lookup succeeds but the entry -// read fails. We can simulate by destroying the virtual fd after the -// stream is created with autoClose:false. -{ - myVfs.writeFileSync('/lz.txt', 'data'); - const fd = myVfs.openSync('/lz.txt'); - const rs = myVfs.createReadStream('/lz.txt', { fd, autoClose: true }); - rs.on('error', common.mustCall(() => {})); - // Trigger _read on next tick; before that, close the fd via the vfs - // so the lazy lookup hits `if (!vfd)` (already covered) but #close in - // _destroy will swallow its own duplicate-close error. - myVfs.closeSync(fd); - rs.resume(); -} - -// Read stream with autoClose:true and an error during _read — covers -// the close-error swallow path inside #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 destroyed before write() — covers the destroyed-true branch -// in _write. -{ - const ws = myVfs.createWriteStream('/wd.txt'); - ws.on('error', () => {}); - ws.destroy(new Error('boom')); -} - -// Read stream with explicit start beyond file end → remaining <= 0 → push null -{ - 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()); -} diff --git a/test/parallel/test-vfs-streams-misc.js b/test/parallel/test-vfs-streams-misc.js deleted file mode 100644 index c45ddf34f26191..00000000000000 --- a/test/parallel/test-vfs-streams-misc.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; - -// Cover stream paths not exercised by other tests: -// - WriteStream basic write + close -// - createReadStream with start/end slicing -// - createReadStream with explicit fd -// - WriteStream with explicit fd -// - WriteStream with start position -// - error paths (open fails, EBADF on broken fd) - -const common = require('../common'); -const assert = require('assert'); -const { Readable } = require('stream'); -const { pipeline } = require('stream/promises'); -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 with start/end slicing -readStream(myVfs.createReadStream('/file.txt', { start: 6, end: 10 })) - .then(common.mustCall((s) => assert.strictEqual(s, 'world'))); - -// Read entire file -readStream(myVfs.createReadStream('/file.txt')) - .then(common.mustCall((s) => assert.strictEqual(s, 'hello world'))); - -// 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); - })); -} - -// Read of a nonexistent file emits 'error' (path not opened) — open is async -{ - const stream = myVfs.createReadStream('/missing.txt'); - stream.on('error', common.mustCall((err) => { - assert.strictEqual(err.code, 'ENOENT'); - })); -} - -// Write basic -(async () => { - 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()); - -// Write with start position writes from there onward -(async () => { - myVfs.writeFileSync('/pad.txt', 'AAAAAAAAAA'); - await pipeline( - Readable.from([Buffer.from('XX')]), - myVfs.createWriteStream('/pad.txt', { start: 3, flags: 'r+' }), - ); - const got = myVfs.readFileSync('/pad.txt', 'utf8'); - assert.strictEqual(got, 'AAAXXAAAAA'); -})().then(common.mustCall()); - -// Write with string chunk + encoding -(async () => { - 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()); - -// Write 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 invalid path (no parent directory) emits error -{ - const stream = myVfs.createWriteStream('/non-existent-dir/file.txt'); - stream.on('error', common.mustCall((err) => { - assert.ok(err); - })); -} - -// path getter -{ - const rs = myVfs.createReadStream('/file.txt'); - assert.strictEqual(rs.path, '/file.txt'); - rs.destroy(); - - const ws = myVfs.createWriteStream('/p.txt'); - assert.strictEqual(ws.path, '/p.txt'); - ws.destroy(); -} diff --git a/test/parallel/test-vfs-streams.js b/test/parallel/test-vfs-streams.js index 5a3361fc359091..9e8344f9ccefea 100644 --- a/test/parallel/test-vfs-streams.js +++ b/test/parallel/test-vfs-streams.js @@ -210,3 +210,92 @@ const vfs = require('node:vfs'); }); })); } + +// ==================== 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..38ff97ce6355b5 --- /dev/null +++ b/test/parallel/test-vfs-symlinks.js @@ -0,0 +1,55 @@ +'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-utimes.js b/test/parallel/test-vfs-utimes.js new file mode 100644 index 00000000000000..28c07ba452740c --- /dev/null +++ b/test/parallel/test-vfs-utimes.js @@ -0,0 +1,26 @@ +'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-file-handle-base.js b/test/parallel/test-vfs-virtual-file-handle.js similarity index 100% rename from test/parallel/test-vfs-file-handle-base.js rename to test/parallel/test-vfs-virtual-file-handle.js diff --git a/test/parallel/test-vfs-provider-base.js b/test/parallel/test-vfs-virtual-provider.js similarity index 100% rename from test/parallel/test-vfs-provider-base.js rename to test/parallel/test-vfs-virtual-provider.js 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..8f89a8764067ab --- /dev/null +++ b/test/parallel/test-vfs-watch-abort-signal.js @@ -0,0 +1,54 @@ +'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-async.js b/test/parallel/test-vfs-watch-async.js deleted file mode 100644 index e1363e0d5ff716..00000000000000 --- a/test/parallel/test-vfs-watch-async.js +++ /dev/null @@ -1,93 +0,0 @@ -'use strict'; - -// Cover VFSWatchAsyncIterable: promise-based watch(). - -const common = require('../common'); -const assert = require('assert'); -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); -myVfs.writeFileSync('/file.txt', 'a'); - -// Basic async iter — receive at least one change event -(async () => { - const iter = myVfs.promises.watch('/file.txt', { interval: 25 }); - setTimeout(() => myVfs.writeFileSync('/file.txt', 'changed'), 60); - for await (const evt of iter) { - assert.strictEqual(evt.eventType, 'change'); - break; // closes via return() - } -})().then(common.mustCall()); - -// Pre-aborted signal -> resolves immediately as done -(async () => { - const ac = new AbortController(); - ac.abort(); - const iter = myVfs.promises.watch('/file.txt', { signal: ac.signal }); - const r = await iter.next(); - assert.strictEqual(r.done, true); -})().then(common.mustCall()); - -// Abort mid-flight -> rejects pending next() with AbortError -(async () => { - const ac = new AbortController(); - const iter = myVfs.promises.watch('/file.txt', { - signal: ac.signal, - interval: 1000, - }); - const pending = iter.next(); - setTimeout(() => ac.abort(), 20); - try { - await pending; - throw new Error('Expected rejection'); - } catch (err) { - assert.strictEqual(err.name, 'AbortError'); - } -})().then(common.mustCall()); - -// throw() on the iterator closes the watcher -(async () => { - const iter = myVfs.promises.watch('/file.txt', { interval: 1000 }); - const r = await iter.throw(new Error('go away')); - assert.strictEqual(r.done, true); -})().then(common.mustCall()); - -// Sync watch() also covers the basic flow -{ - const myVfs2 = vfs.create(); - myVfs2.writeFileSync('/file.txt', 'a'); - const watcher = myVfs2.watch('/file.txt', { interval: 25 }, - common.mustCallAtLeast(() => {}, 1)); - setTimeout(() => { - myVfs2.writeFileSync('/file.txt', 'b'); - setTimeout(() => watcher.close(), 100); - }, 30); -} - -// Recursive directory watch -{ - const myVfs3 = vfs.create(); - myVfs3.mkdirSync('/d/sub', { recursive: true }); - myVfs3.writeFileSync('/d/sub/file.txt', 'x'); - const watcher = myVfs3.watch('/d', { interval: 25, recursive: true }, - common.mustCallAtLeast(() => {}, 1)); - setTimeout(() => { - myVfs3.writeFileSync('/d/sub/file.txt', 'changed'); - setTimeout(() => watcher.close(), 100); - }, 30); -} - -// Buffer encoding -{ - const myVfs4 = vfs.create(); - myVfs4.writeFileSync('/file.txt', 'a'); - const watcher = myVfs4.watch('/file.txt', { interval: 25, encoding: 'buffer' }, - common.mustCallAtLeast((eventType, filename) => { - assert.strictEqual(eventType, 'change'); - assert.ok(Buffer.isBuffer(filename) || filename === null); - }, 1)); - setTimeout(() => { - myVfs4.writeFileSync('/file.txt', 'b'); - setTimeout(() => watcher.close(), 100); - }, 30); -} diff --git a/test/parallel/test-vfs-watch-directory.js b/test/parallel/test-vfs-watch-directory.js index a13563bac2ab6c..d2a68ceb76fb9a 100644 --- a/test/parallel/test-vfs-watch-directory.js +++ b/test/parallel/test-vfs-watch-directory.js @@ -2,10 +2,12 @@ // Tests for VFS directory watching: // - watch() on directories reports child changes -// - Recursive watchers discover descendants created after startup +// - 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. @@ -25,26 +27,41 @@ const vfs = require('node:vfs'); setTimeout(() => myVfs.writeFileSync('/parent/file.txt', 'y'), 100); } -// Files created after a recursive watcher starts must still trigger events. -{ - const myVfs = vfs.create(); - myVfs.mkdirSync('/parent', { recursive: true }); +(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(); + } - let gotEvent = false; - const watcher = myVfs.watch('/parent', { - recursive: true, - interval: 50, - persistent: false, - }); - watcher.on('change', common.mustCallAtLeast((eventType, filename) => { - if (filename === 'new.txt') { - gotEvent = true; - } - })); + // 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(); + } - setTimeout(() => myVfs.writeFileSync('/parent/new.txt', 'first'), 70); - setTimeout(common.mustCall(() => { + // 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(); - assert.strictEqual(gotEvent, true); - }), 300); -} + } +})().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..baa5c3d0373f79 --- /dev/null +++ b/test/parallel/test-vfs-watch-encoding.js @@ -0,0 +1,20 @@ +'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..5be150548b1bf2 --- /dev/null +++ b/test/parallel/test-vfs-watch-promises.js @@ -0,0 +1,65 @@ +'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..9d9e7aebc0bd59 --- /dev/null +++ b/test/parallel/test-vfs-watch-recursive.js @@ -0,0 +1,33 @@ +'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..fb7c1c3ed8b47e --- /dev/null +++ b/test/parallel/test-vfs-watch.js @@ -0,0 +1,74 @@ +'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-watcher-branches.js b/test/parallel/test-vfs-watcher-branches.js deleted file mode 100644 index 49f99836c9235f..00000000000000 --- a/test/parallel/test-vfs-watcher-branches.js +++ /dev/null @@ -1,145 +0,0 @@ -'use strict'; - -// Branch coverage for VFSWatcher / VFSStatWatcher / VFSWatchAsyncIterable. - -const common = require('../common'); -const assert = require('assert'); -const { once } = require('events'); -const vfs = require('node:vfs'); - -(async () => { - // close() while a poll is in-flight after #closed flag is set — - // close + close again is the simplest #closed-true branch. - { - const myVfs = vfs.create(); - myVfs.writeFileSync('/x.txt', 'a'); - const watcher = myVfs.watch('/x.txt'); - watcher.close(); - watcher.close(); // second close is a no-op (#closed already true) - } - - // 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 directory and deleting a tracked file (covers the - // `file deleted` path in #pollDirectory). - { - 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(); - } - - // Watching a directory whose listing fails mid-poll: delete the - // directory itself to trigger the `try/catch { /* ignore */ }` - // around readdirSync inside #pollDirectory. - { - 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 }); - // give the poll one tick - await new Promise((r) => setTimeout(r, 60)); - watcher.close(); - } - - // VFSStatWatcher with bigint option — covers ctime/size branches and - // the bigint createZeroStats path. - { - const myVfs = vfs.create(); - myVfs.writeFileSync('/sw.txt', 'a'); - let listener; - const fired = new Promise((resolve) => { - listener = (curr, prev) => { - assert.strictEqual(typeof curr.size, 'bigint'); - assert.strictEqual(typeof prev.size, 'bigint'); - resolve(); - }; - }); - myVfs.watchFile('/sw.txt', { interval: 25, bigint: true }, listener); - myVfs.writeFileSync('/sw.txt', 'changed!!!!'); - await fired; - myVfs.unwatchFile('/sw.txt', listener); - } - - // VFSStatWatcher default interval (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); - } - - // VFSStatWatcher: stop on already-stopped watcher is a no-op - { - const myVfs = vfs.create(); - myVfs.writeFileSync('/sw2.txt', 'a'); - const listener = () => {}; - myVfs.watchFile('/sw2.txt', { interval: 25 }, listener); - myVfs.unwatchFile('/sw2.txt', listener); - // unwatch again - myVfs.unwatchFile('/sw2.txt', listener); - } - - // Async iterable: emit a change while a next() is outstanding (covers the - // pendingResolvers shift path) - { - const myVfs = vfs.create(); - myVfs.writeFileSync('/q.txt', 'a'); - const iter = myVfs.promises.watch('/q.txt', { interval: 25 }); - const pending = iter.next(); - myVfs.writeFileSync('/q.txt', 'BBBBBBBB'); - const r = await pending; - if (!r.done) assert.strictEqual(r.value.eventType, 'change'); - await iter.return(); - } - - // Async iterable throw() closes the watcher and resolves with done:true - { - const myVfs = vfs.create(); - myVfs.writeFileSync('/q2.txt', 'a'); - const iter = myVfs.promises.watch('/q2.txt', { interval: 1000 }); - const r = await iter.throw(new Error('boom')); - assert.strictEqual(r.done, true); - } - - // Async iterable: queue-fill path — keep modifying without consuming. - { - const myVfs = vfs.create(); - myVfs.writeFileSync('/q3.txt', 'a'); - const iter = myVfs.promises.watch('/q3.txt', { interval: 25 }); - for (let i = 0; i < 5; i++) { - myVfs.writeFileSync('/q3.txt', 'x'.repeat(i + 5)); - await new Promise((r) => setTimeout(r, 30)); - } - // Drain at least one event - const r = await iter.next(); - assert.ok(r.value || r.done); - await iter.return(); - } - - // Async iterable: close while a resolver is pending — drains via the - // 'close' event handler (covers the close-event resolver-loop branch). - { - const myVfs = vfs.create(); - myVfs.writeFileSync('/q4.txt', 'a'); - const iter = myVfs.promises.watch('/q4.txt', { interval: 1000 }); - const pending = iter.next(); - // Queue iter.return() on a microtask so it runs before pending resolves - queueMicrotask(() => iter.return()); - const r = await pending; - assert.strictEqual(r.done, true); - } -})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-watcher-coverage.js b/test/parallel/test-vfs-watcher-coverage.js deleted file mode 100644 index 91fee0e5afd47e..00000000000000 --- a/test/parallel/test-vfs-watcher-coverage.js +++ /dev/null @@ -1,124 +0,0 @@ -'use strict'; - -// Cover VFSWatcher edge cases. Run blocks sequentially. Use distinct -// content lengths so size-based stat-change detection always fires -// (mtime granularity is millisecond which can collide on synchronous -// writes within the same poll tick). - -const common = require('../common'); -const assert = require('assert'); -const { once } = require('events'); -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(); - } - - // Listener add/remove + ref/unref - { - 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(); - } - - // Buffer encoding — filename arrives as Buffer - { - 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', 'bbbbbbbb'); - const [eventType, filename] = await changed; - assert.strictEqual(eventType, 'change'); - assert.ok(Buffer.isBuffer(filename)); - watcher.close(); - } - - // Recursive directory watch — observe a 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(); - } - - // 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(); - } - - // Async iterable: events queued and drained via next() - { - const myVfs = vfs.create(); - myVfs.writeFileSync('/q.txt', 'a'); - const iter = myVfs.promises.watch('/q.txt', { interval: 25 }); - myVfs.writeFileSync('/q.txt', 'bbbbbbbb'); - const r = await iter.next(); - if (!r.done) assert.strictEqual(r.value.eventType, 'change'); - await iter.return(); - } - - // VFSStatWatcher fires on content change - { - const myVfs = vfs.create(); - myVfs.writeFileSync('/sw.txt', 'a'); - let listener; - const fired = new Promise((resolve) => { - listener = (curr, prev) => { - assert.strictEqual(typeof curr.size, 'number'); - assert.strictEqual(typeof prev.size, 'number'); - resolve(); - }; - }); - myVfs.watchFile('/sw.txt', { interval: 25 }, listener); - myVfs.writeFileSync('/sw.txt', 'changed!!!!'); - await fired; - myVfs.unwatchFile('/sw.txt', listener); - } - - // 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(); - } -})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-watchfile.js b/test/parallel/test-vfs-watchfile.js index a3a0bc17643abd..7502cbf580e258 100644 --- a/test/parallel/test-vfs-watchfile.js +++ b/test/parallel/test-vfs-watchfile.js @@ -1,35 +1,108 @@ 'use strict'; -// Tests for VFS watchFile/unwatchFile: -// - unwatchFile(path) without a specific listener cleans up properly -// - watchFile() zero stats for missing file use all-zero mode +// Tests for VFS watchFile/unwatchFile. -require('../common'); +const common = require('../common'); const assert = require('assert'); const vfs = require('node:vfs'); -// unwatchFile(path) without a specific listener must clean up the timer. -// If the fix is wrong, the process would hang due to a leaked timer. +// 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'); } -// watchFile() zero stats for a missing file must have all-zero mode. -// The previous-stats argument for a newly-created file should report -// isFile() === false and mode === 0. +// 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'); + let listener; + const fired = new Promise((resolve) => { + listener = (curr, prev) => { + assert.strictEqual(typeof curr.size, 'number'); + assert.strictEqual(typeof prev.size, 'number'); + resolve(); + }; + }); + myVfs.watchFile('/sw.txt', { interval: 25 }, listener); + myVfs.writeFileSync('/sw.txt', 'longer-content-changed'); + await fired; + myVfs.unwatchFile('/sw.txt', listener); +})().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'); + let listener; + const fired = new Promise((resolve) => { + listener = (curr, prev) => { + assert.strictEqual(typeof curr.size, 'bigint'); + assert.strictEqual(typeof prev.size, 'bigint'); + resolve(); + }; + }); + myVfs.watchFile('/bi.txt', { interval: 25, bigint: true }, listener); + myVfs.writeFileSync('/bi.txt', 'longer-content-changed'); + await fired; + myVfs.unwatchFile('/bi.txt', listener); +})().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 && 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..953d9c740e5eb6 --- /dev/null +++ b/test/parallel/test-vfs-write-options.js @@ -0,0 +1,32 @@ +'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'); + })); +} From 03c7bc9be8e612a52171bca174c36753222284c7 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 3 May 2026 21:24:42 +0200 Subject: [PATCH 10/14] vfs: gate node:vfs behind --experimental-vfs flag Adds an --experimental-vfs runtime option that gates loading of the node:vfs builtin module, matching the pattern used by node:quic and node:stream/iter. Without the flag, require('node:vfs') / import 'node:vfs' throw ERR_UNKNOWN_BUILTIN_MODULE. All VFS test files are updated to pass --experimental-vfs. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- doc/api/cli.md | 12 +++++ doc/api/vfs.md | 3 +- lib/internal/bootstrap/realm.js | 2 +- lib/internal/process/pre_execution.js | 10 ++++ src/node_options.cc | 4 ++ src/node_options.h | 1 + test/parallel/test-vfs-access-modes.js | 1 + test/parallel/test-vfs-append-write.js | 1 + test/parallel/test-vfs-bigint-position.js | 1 + test/parallel/test-vfs-callback-api.js | 1 + test/parallel/test-vfs-copyfile-mode.js | 1 + test/parallel/test-vfs-create.js | 1 + test/parallel/test-vfs-ctime-update.js | 1 + test/parallel/test-vfs-dir-handle.js | 1 + test/parallel/test-vfs-fd.js | 1 + test/parallel/test-vfs-file-handle.js | 1 + test/parallel/test-vfs-flag.js | 48 +++++++++++++++++++ test/parallel/test-vfs-hardlink-nlink.js | 1 + test/parallel/test-vfs-link.js | 1 + test/parallel/test-vfs-memory-file-handle.js | 2 +- .../test-vfs-memory-provider-dynamic.js | 2 +- .../test-vfs-memory-provider-flags.js | 1 + test/parallel/test-vfs-memory-provider.js | 2 +- test/parallel/test-vfs-mkdir.js | 1 + test/parallel/test-vfs-mkdtemp.js | 1 + test/parallel/test-vfs-parent-timestamps.js | 1 + test/parallel/test-vfs-promises-open.js | 1 + test/parallel/test-vfs-promises.js | 2 +- .../test-vfs-readdir-symlink-recursive.js | 1 + test/parallel/test-vfs-readfile-async.js | 1 + test/parallel/test-vfs-readfile-encoding.js | 1 + test/parallel/test-vfs-readfile-flag.js | 1 + .../parallel/test-vfs-real-provider-handle.js | 2 +- .../test-vfs-real-provider-promises.js | 1 + .../test-vfs-real-provider-symlinks.js | 1 + test/parallel/test-vfs-real-provider-watch.js | 1 + test/parallel/test-vfs-real-provider.js | 1 + test/parallel/test-vfs-rename.js | 1 + test/parallel/test-vfs-rm-edge-cases.js | 1 + test/parallel/test-vfs-rmdir-symlink.js | 1 + test/parallel/test-vfs-stats-bigint.js | 1 + test/parallel/test-vfs-stats-helpers.js | 2 +- test/parallel/test-vfs-stats-ino-dev.js | 1 + test/parallel/test-vfs-stream-errors.js | 1 + test/parallel/test-vfs-stream-explicit-fd.js | 1 + test/parallel/test-vfs-stream-properties.js | 1 + test/parallel/test-vfs-stream-validation.js | 1 + test/parallel/test-vfs-streams.js | 1 + test/parallel/test-vfs-symlinks.js | 1 + test/parallel/test-vfs-truncate-negative.js | 1 + test/parallel/test-vfs-utimes.js | 1 + test/parallel/test-vfs-virtual-file-handle.js | 2 +- test/parallel/test-vfs-virtual-provider.js | 1 + test/parallel/test-vfs-watch-abort-signal.js | 1 + test/parallel/test-vfs-watch-directory.js | 1 + test/parallel/test-vfs-watch-encoding.js | 1 + test/parallel/test-vfs-watch-promises.js | 1 + test/parallel/test-vfs-watch-recursive.js | 1 + test/parallel/test-vfs-watch.js | 1 + test/parallel/test-vfs-watchfile.js | 1 + test/parallel/test-vfs-write-options.js | 1 + 61 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 test/parallel/test-vfs-flag.js diff --git a/doc/api/cli.md b/doc/api/cli.md index b5428453787e4a..3b77a2af64b24c 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1332,6 +1332,16 @@ added: v25.9.0 Enable the experimental [`node:stream/iter`][] module. +### `--experimental-vfs` + + + +> Stability: 1 - Experimental + +Enable the experimental [`node:vfs`][] module. + ### `--experimental-test-coverage` -The `node:vfs` module provides a virtual file system that can be mounted -alongside the real file system. Virtual files can be read using standard `node:fs` -operations and loaded as modules using `require()` or `import`. +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: @@ -27,158 +28,23 @@ 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. -## Overview - -The Virtual File System (VFS) allows you to create in-memory file systems that -integrate seamlessly with the Node.js `node:fs` module and module loading system. This -is useful for: - -* Bundling assets in Single Executable Applications (SEA) -* Testing file system operations without touching the disk -* Creating virtual module systems -* Embedding configuration or data files in applications - -## Operating modes - -The VFS supports two operating modes: - -### Standard mode (default) - -When mounted at a path prefix (e.g., `/virtual`), the VFS handles **all** -operations for paths starting with that prefix. The VFS completely shadows -any real file system paths under the mount point. - -### Overlay mode - -When created with `{ overlay: true }`, the VFS selectively intercepts only -paths that exist within the VFS. Paths that don't exist in the VFS fall through -to the real file system. This is useful for mocking specific files while leaving -others unchanged. - -```cjs -const vfs = require('node:vfs'); -const fs = require('node:fs'); - -// Overlay mode: only intercept files that exist in VFS -const myVfs = vfs.create({ overlay: true }); -myVfs.writeFileSync('/etc/config.json', JSON.stringify({ mocked: true })); -myVfs.mount('/'); - -// This reads from VFS (file exists in VFS) -fs.readFileSync('/etc/config.json', 'utf8'); // '{"mocked": true}' - -// This reads from real FS (file doesn't exist in VFS) -fs.readFileSync('/etc/hostname', 'utf8'); // Real file content -``` - -See [Security considerations][] for important warnings about overlay mode. - -## Debugging - -Set `NODE_DEBUG=vfs` to log VFS mount, routing, and module-loading decisions to -`stderr`. - -```console -$ NODE_DEBUG=vfs node app.js -VFS 12345: mount /virtual overlay=false moduleHooks=true virtualCwd=false -VFS 12345: register mount=/virtual overlay=false active=1 -VFS 12345: read /virtual/app/config.json -> hit (mount=/virtual overlay=false) -``` - ## Basic usage -The following example shows how to create a virtual file system, add files, -and access them through the standard `node:fs` API: - -```mjs -import vfs from 'node:vfs'; -import fs from 'node:fs'; - -// Create a new virtual file system -const myVfs = vfs.create(); - -// Create directories and files -myVfs.mkdirSync('/app'); -myVfs.writeFileSync('/app/config.json', JSON.stringify({ port: 3000 })); -myVfs.writeFileSync('/app/greet.js', 'module.exports = (name) => "Hello, " + name + "!";'); - -// Mount the VFS at a path prefix -myVfs.mount('/virtual'); - -// Now standard fs operations work on the virtual files -const config = JSON.parse(fs.readFileSync('/virtual/app/config.json', 'utf8')); -console.log(config.port); // 3000 - -// Modules can be required from the VFS -const greet = await import('/virtual/app/greet.js'); -console.log(greet.default('World')); // Hello, World! - -// Clean up -myVfs.unmount(); -``` - ```cjs const vfs = require('node:vfs'); -const fs = require('node:fs'); -// Create a new virtual file system const myVfs = vfs.create(); +myVfs.mkdirSync('/dir', { recursive: true }); +myVfs.writeFileSync('/dir/hello.txt', 'Hello, VFS!'); -// Create directories and files -myVfs.mkdirSync('/app'); -myVfs.writeFileSync('/app/config.json', JSON.stringify({ port: 3000 })); -myVfs.writeFileSync('/app/greet.js', 'module.exports = (name) => "Hello, " + name + "!";'); - -// Mount the VFS at a path prefix -myVfs.mount('/virtual'); - -// Now standard fs operations work on the virtual files -const config = JSON.parse(fs.readFileSync('/virtual/app/config.json', 'utf8')); -console.log(config.port); // 3000 - -// Modules can be required from the VFS -const greet = require('/virtual/app/greet.js'); -console.log(greet('World')); // Hello, World! - -// Clean up -myVfs.unmount(); +console.log(myVfs.readFileSync('/dir/hello.txt', 'utf8')); // 'Hello, VFS!' ``` -## Limitations - -The VFS has the following limitations: - -### Native addons - -Native addons (`.node` files) cannot be loaded from the VFS. Native addons -must exist on the real file system because they are loaded by the operating -system's dynamic linker, which cannot access virtual files. - -### Child processes - -Other processes, including any child processes of the Node.js process, cannot -access virtual file systems. Node.js child processes do not inherit the -parent's VFS mounts. - -### Worker threads - -Each worker thread has its own independent VFS state. A VFS mounted in the -main thread is not automatically available in worker threads. To use VFS in -workers, create and mount a new VFS instance within each worker. - -### `fs.watch` limitations - -The `fs.watch()` and `fs.watchFile()` functions work with VFS files but use -polling internally rather than native file system notifications, since VFS -files exist only in memory. - -### Code caching in SEA - -When using VFS with Single Executable Applications, the `useCodeCache` option -in the SEA configuration does not currently apply to modules loaded from the -VFS. This is a current limitation due to incomplete implementation, not a -technical impossibility. Consider bundling the application to enable code -caching and do not rely on module loading in 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])` @@ -186,47 +52,23 @@ caching and do not rely on module loading in VFS. added: REPLACEME --> -* `provider` {VirtualProvider} Optional provider instance. Defaults to a new - `MemoryProvider`. +* `provider` {VirtualProvider} The provider to use. **Default:** + `new MemoryProvider()`. * `options` {Object} - * `moduleHooks` {boolean} Whether to enable `require()`/`import` hooks for - loading modules from the VFS. **Default:** `true`. - * `virtualCwd` {boolean} Whether to enable virtual working directory support. - **Default:** `false`. - * `overlay` {boolean} Whether to enable overlay mode. In overlay mode, the VFS - only intercepts paths that exist in the VFS, allowing other paths to fall - through to the real file system. Useful for mocking specific files while - leaving others unchanged. See [Security considerations][] for important - warnings. **Default:** `false`. + * `emitExperimentalWarning` {boolean} Whether to emit the experimental + warning when the instance is created. **Default:** `true`. * Returns: {VirtualFileSystem} -Creates a new `VirtualFileSystem` instance. If no provider is specified, a -`MemoryProvider` is used, which stores files in memory. - -```mjs -import vfs from 'node:vfs'; - -// Create with default MemoryProvider -const memoryVfs = vfs.create(); - -// Create with explicit provider -const customVfs = vfs.create(new vfs.MemoryProvider()); - -// Create with options only -const vfsWithOptions = vfs.create({ moduleHooks: false }); -``` +Convenience factory equivalent to `new VirtualFileSystem(provider, options)`. ```cjs const vfs = require('node:vfs'); -// Create with default MemoryProvider +// Default in-memory provider const memoryVfs = vfs.create(); -// Create with explicit provider -const customVfs = vfs.create(new vfs.MemoryProvider()); - -// Create with options only -const vfsWithOptions = vfs.create({ moduleHooks: false }); +// Explicit provider +const realVfs = vfs.create(new vfs.RealFSProvider('/tmp/sandbox')); ``` ## Class: `VirtualFileSystem` @@ -235,9 +77,8 @@ const vfsWithOptions = vfs.create({ moduleHooks: false }); added: REPLACEME --> -The `VirtualFileSystem` class provides a file system interface backed by a -provider. It supports standard file system operations and can be mounted to -make virtual files accessible through the `node:fs` module. +A `VirtualFileSystem` wraps a [`VirtualProvider`][] and exposes an +`fs`-like API. Each instance maintains its own file tree. ### `new VirtualFileSystem([provider][, options])` @@ -245,142 +86,11 @@ make virtual files accessible through the `node:fs` module. added: REPLACEME --> -* `provider` {VirtualProvider} The provider to use. **Default:** `MemoryProvider`. +* `provider` {VirtualProvider} The provider to use. **Default:** + `new MemoryProvider()`. * `options` {Object} - * `moduleHooks` {boolean} Enable module loading hooks. **Default:** `true`. - * `virtualCwd` {boolean} Enable virtual working directory. **Default:** `false`. - -Creates a new `VirtualFileSystem` instance. - -Multiple `VirtualFileSystem` instances can be created and used independently. -Each instance maintains its own file tree and can be mounted at different -paths. However, only one VFS can be mounted at a given path prefix at a time. -If two VFS instances are mounted at overlapping paths (e.g., `/virtual` and -`/virtual/sub`), the more specific path takes precedence for matching paths. - -### `vfs.chdir(path)` - - - -* `path` {string} The new working directory path within the VFS. - -Changes the virtual working directory. This only affects path resolution within -the VFS when `virtualCwd` is enabled in the constructor options. - -Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. - -When mounted with `virtualCwd` enabled, the VFS also hooks `process.chdir()` and -`process.cwd()` to support virtual paths transparently. In Worker threads, -`process.chdir()` to virtual paths will work, but attempting to change to real -file system paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. - -### `vfs.cwd()` - - - -* Returns: {string|null} - -Returns the current virtual working directory, or `null` if no virtual directory -has been set yet. - -Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. - -### `vfs.mount(prefix)` - - - -* `prefix` {string} The path prefix where the VFS will be mounted. -* Returns: {VirtualFileSystem} The VFS instance (for chaining or `using`). - -Mounts the virtual file system at the specified path prefix. After mounting, -files in the VFS can be accessed via the `node:fs` module using paths that start -with the prefix. - -If a real file system path already exists at the mount prefix, the VFS -**shadows** that path. All operations to paths under the mount prefix will be -directed to the VFS, making the real files inaccessible until the VFS is -unmounted. See [Security considerations][] for important warnings about this -behavior. - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); -myVfs.writeFileSync('/data.txt', 'Hello'); -myVfs.mount('/virtual'); - -// Now accessible as /virtual/data.txt -require('node:fs').readFileSync('/virtual/data.txt', 'utf8'); // 'Hello' -``` - -On Windows, mount paths use drive letters: - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); -myVfs.writeFileSync('/data.txt', 'Hello'); -myVfs.mount('C:\\virtual'); - -// Now accessible as C:\virtual\data.txt -require('node:fs').readFileSync('C:\\virtual\\data.txt', 'utf8'); // 'Hello' -``` - -The VFS supports the [Explicit Resource Management][] proposal. Use the `using` -declaration to automatically unmount when leaving scope: - -```cjs -const vfs = require('node:vfs'); -const fs = require('node:fs'); - -{ - using myVfs = vfs.create(); - myVfs.writeFileSync('/data.txt', 'Hello'); - myVfs.mount('/virtual'); - - fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello' -} // VFS is automatically unmounted here - -fs.existsSync('/virtual/data.txt'); // false - VFS is unmounted -``` - -### `vfs.mounted` - - - -* {boolean} - -Returns `true` if the VFS is currently mounted. - -### `vfs.mountPoint` - - - -* {string | null} - -The current mount point as an absolute path, or `null` if not mounted. - -### `vfs.overlay` - - - -* {boolean} - -Returns `true` if overlay mode is enabled. In overlay mode, the VFS only -intercepts paths that exist in the VFS, allowing other paths to fall through -to the real file system. + * `emitExperimentalWarning` {boolean} Whether to emit the experimental + warning. **Default:** `true`. ### `vfs.provider` @@ -390,19 +100,7 @@ added: REPLACEME * {VirtualProvider} -The underlying provider for this VFS instance. Can be used to access -provider-specific methods like `setReadOnly()` for `MemoryProvider`. - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Access the provider -console.log(myVfs.provider.readonly); // false -myVfs.provider.setReadOnly(); -console.log(myVfs.provider.readonly); // true -``` +The provider backing this VFS instance. ### `vfs.readonly` @@ -412,184 +110,121 @@ added: REPLACEME * {boolean} -Returns `true` if the underlying provider is read-only. - -### `vfs.unmount()` - - - -Unmounts the virtual file system. After unmounting, virtual files are no longer -accessible through the `node:fs` module. The VFS can be remounted at the same or a -different path by calling `mount()` again. Unmounting also resets the virtual -working directory if one was set. - -This method is idempotent: calling `unmount()` on an already unmounted VFS -has no effect. - -### File System Methods - -The `VirtualFileSystem` class provides methods that mirror the `node:fs` module API. -All paths are relative to the VFS root (not the mount point). - -These methods accept the same argument types as their `node:fs` counterparts, -including `string`, `Buffer`, `TypedArray`, and `DataView` where applicable. - -#### Overlay mode behavior - -When overlay mode is enabled, the following behavior applies to `node:fs` operations -on mounted paths. - -**Path encoding:** The VFS uses UTF-8 encoding for file and directory names -internally. In overlay mode, path matching is performed using the VFS's UTF-8 -encoding. When falling through to the real file system, paths are passed to -the native file system APIs which handle encoding according to platform -conventions (UTF-8 on most Unix systems, UTF-16 on Windows). This means the -VFS inherits the underlying file system's encoding behavior for paths that -fall through, while VFS-internal paths always use UTF-8. - -**Case sensitivity:** The VFS is always case-sensitive internally. In overlay -mode, this can cause unexpected behavior when overlaying a case-insensitive -file system (such as macOS HFS+ or Windows NTFS): - -* A VFS file at `/Data.txt` will not shadow a real file at `/data.txt` -* Looking up `/DATA.TXT` will fall through to the real file system (not found - in case-sensitive VFS), potentially finding a real file with different casing -* This mismatch is intentional: the VFS maintains consistent cross-platform - behavior rather than emulating the underlying file system's case handling - -If case-insensitive matching is required, applications should normalize paths -before VFS operations. - -**Operation routing:** - -* **Read operations** (`readFile`, `readdir`, `stat`, `lstat`, `access`, - `exists`, `realpath`, `readlink`, `statfs`, `opendir`): Check VFS first. If - the path doesn't exist in VFS, fall through to the real file system. -* **Write operations** (`writeFile`, `appendFile`, `mkdir`, `rename`, `unlink`, - `rmdir`, `symlink`, `copyFile`, `truncate`, `link`, `chmod`, `chown`, - `utimes`, `lutimes`, `mkdtemp`, `rm`, `cp`): Always operate on VFS. New - files are created in VFS, and attempting to modify a real file that doesn't - exist in VFS will create a new VFS file instead. -* **File descriptors**: Once a file is opened, all subsequent operations on that - descriptor stay within the same layer (VFS or real FS) where it was opened. - -#### Synchronous Methods - -The `VirtualFileSystem` class supports all common synchronous `node:fs` methods -for reading, writing, and managing files and directories. Methods mirror the -`node:fs` module API. - -#### Promise Methods - -All synchronous methods have promise-based equivalents available through -`vfs.promises`: - -```mjs -import vfs from 'node:vfs'; - -const myVfs = vfs.create(); - -await myVfs.promises.writeFile('/data.txt', 'Hello'); -const content = await myVfs.promises.readFile('/data.txt', 'utf8'); -console.log(content); // 'Hello' -``` +`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'); -const myVfs = vfs.create(); - async function example() { - await myVfs.promises.writeFile('/data.txt', 'Hello'); - const content = await myVfs.promises.readFile('/data.txt', 'utf8'); - console.log(content); // 'Hello' + const myVfs = vfs.create(); + await myVfs.promises.writeFile('/file.txt', 'hello'); + const data = await myVfs.promises.readFile('/file.txt', 'utf8'); + return data; } +example(); ``` -## Class: `VirtualProvider` - - - -The `VirtualProvider` class is an abstract base class for VFS providers. -Providers implement the actual file system storage and operations. - -### `provider.readonly` - - - -* {boolean} - -Returns `true` if the provider is read-only. +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`. -### `provider.supportsSymlinks` - - - -* {boolean} - -Returns `true` if the provider supports symbolic links. - -### `provider.supportsWatch` +## Class: `VirtualProvider` -* {boolean} +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`, ...). -Returns `true` if the provider supports file watching via `watch()`, -`watchFile()`, and `unwatchFile()`. +### Capability flags -### Creating Custom Providers +* `provider.readonly` {boolean} **Default:** `false`. +* `provider.supportsSymlinks` {boolean} **Default:** `false`. +* `provider.supportsWatch` {boolean} **Default:** `false`. -To create a custom provider, extend `VirtualProvider` and implement the -required methods: +### Creating custom providers ```cjs const { VirtualProvider } = require('node:vfs'); -class MyProvider extends VirtualProvider { - get readonly() { return false; } - get supportsSymlinks() { return true; } +class StaticProvider extends VirtualProvider { + get readonly() { return true; } - openSync(path, flags, mode) { - // Implementation - } - - statSync(path, options) { - // Implementation - } - - readdirSync(path, options) { - // Implementation - } - - // ... implement other required methods + 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 `MemoryProvider` stores files in memory. It supports full read/write -operations and symbolic links. - -```cjs -const { create, MemoryProvider } = require('node:vfs'); - -const myVfs = create(new 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()` @@ -597,24 +232,20 @@ const myVfs = create(new MemoryProvider()); added: REPLACEME --> -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. +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 myVfs = vfs.create(); - -// Populate the VFS -myVfs.mkdirSync('/app'); -myVfs.writeFileSync('/app/config.json', '{"readonly": true}'); +const provider = new vfs.MemoryProvider(); +const myVfs = vfs.create(provider); +myVfs.writeFileSync('/seed.txt', 'initial'); -// Make it read-only -myVfs.provider.setReadOnly(); +provider.setReadOnly(); -// This would now throw an error -// myVfs.writeFileSync('/app/config.json', 'new content'); +myVfs.writeFileSync('/x.txt', 'fail'); // throws EROFS ``` ## Class: `RealFSProvider` @@ -623,13 +254,10 @@ myVfs.provider.setReadOnly(); added: REPLACEME --> -The `RealFSProvider` wraps a real file system directory, allowing it to be -mounted at a different VFS path. This is useful for: - -* Mounting a directory at a different path -* Enabling `virtualCwd` support in Worker threads (by mounting the real - file system through VFS) -* Creating sandboxed views of real directories +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)` @@ -637,34 +265,14 @@ mounted at a different VFS path. This is useful for: added: REPLACEME --> -* `rootPath` {string} The real file system path to use as the provider root. - -Creates a new `RealFSProvider` that wraps the specified directory. All paths -accessed through this provider are resolved relative to `rootPath`. Path -traversal outside `rootPath` (via `..`) is prevented for security. - -```mjs -import vfs from 'node:vfs'; - -// Mount /home/user/project at /project -const projectVfs = vfs.create(new vfs.RealFSProvider('/home/user/project')); -projectVfs.mount('/project'); - -// Now /project/src/index.js maps to /home/user/project/src/index.js -import fs from 'node:fs'; -const content = fs.readFileSync('/project/src/index.js', 'utf8'); -``` +* `rootPath` {string} The absolute file system path to use as the root. + Must be a non-empty string. ```cjs const vfs = require('node:vfs'); -// Mount /home/user/project at /project -const projectVfs = vfs.create(new vfs.RealFSProvider('/home/user/project')); -projectVfs.mount('/project'); - -// Now /project/src/index.js maps to /home/user/project/src/index.js -const fs = require('node:fs'); -const content = fs.readFileSync('/project/src/index.js', 'utf8'); +const realVfs = vfs.create(new vfs.RealFSProvider('/tmp/sandbox')); +realVfs.writeFileSync('/file.txt', 'hello'); // writes /tmp/sandbox/file.txt ``` ### `realFSProvider.rootPath` @@ -675,387 +283,25 @@ added: REPLACEME * {string} -The real file system path that this provider wraps. - -## Integration with `node:fs` module - -When a VFS is mounted, the standard `node:fs` module automatically routes operations -to the VFS for paths that match the mount prefix: - -```mjs -import vfs from 'node:vfs'; -import fs from 'node:fs'; - -const myVfs = vfs.create(); -myVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); -myVfs.mount('/virtual'); - -// These all work transparently -fs.readFileSync('/virtual/hello.txt', 'utf8'); // Sync -await fs.promises.readFile('/virtual/hello.txt', 'utf8'); // Promise -fs.createReadStream('/virtual/hello.txt'); // Stream - -// Real file system is still accessible -fs.readFileSync('/etc/passwd'); // Real file -``` - -```cjs -const vfs = require('node:vfs'); -const fs = require('node:fs'); - -const myVfs = vfs.create(); -myVfs.writeFileSync('/hello.txt', 'Hello from VFS!'); -myVfs.mount('/virtual'); - -// These all work transparently -fs.readFileSync('/virtual/hello.txt', 'utf8'); // Sync -fs.promises.readFile('/virtual/hello.txt', 'utf8'); // Promise -fs.createReadStream('/virtual/hello.txt'); // Stream - -// Real file system is still accessible -fs.readFileSync('/etc/passwd'); // Real file -``` - -### Intercepted `node:fs` methods - -The following `node:fs` methods are intercepted when a VFS is mounted. Each -method is intercepted in its synchronous, callback, and/or promise form. - -**Path-based read operations** (synchronous, callback, and promise): - -* `existsSync()`, `exists()` -* `statSync()`, `stat()`, `fs.promises.stat()` -* `lstatSync()`, `lstat()`, `fs.promises.lstat()` -* `readFileSync()`, `readFile()`, `fs.promises.readFile()` -* `readdirSync()`, `readdir()`, `fs.promises.readdir()` -* `realpathSync()`, `realpath()`, `fs.promises.realpath()` -* `accessSync()`, `access()`, `fs.promises.access()` -* `readlinkSync()`, `readlink()`, `fs.promises.readlink()` -* `statfsSync()`, `statfs()`, `fs.promises.statfs()` -* `opendirSync()`, `opendir()` - -**Path-based write operations** (synchronous, callback, and promise): - -* `writeFileSync()`, `writeFile()`, `fs.promises.writeFile()` -* `appendFileSync()`, `appendFile()`, `fs.promises.appendFile()` -* `mkdirSync()`, `mkdir()`, `fs.promises.mkdir()` -* `rmdirSync()`, `rmdir()`, `fs.promises.rmdir()` -* `rmSync()`, `rm()`, `fs.promises.rm()` -* `unlinkSync()`, `unlink()`, `fs.promises.unlink()` -* `renameSync()`, `rename()`, `fs.promises.rename()` -* `copyFileSync()`, `copyFile()`, `fs.promises.copyFile()` -* `symlinkSync()`, `symlink()`, `fs.promises.symlink()` -* `truncateSync()`, `truncate()`, `fs.promises.truncate()` -* `linkSync()`, `link()`, `fs.promises.link()` -* `chmodSync()`, `chmod()`, `fs.promises.chmod()` -* `chownSync()`, `chown()`, `fs.promises.chown()` -* `lchownSync()`, `lchown()`, `fs.promises.lchown()` -* `utimesSync()`, `utimes()`, `fs.promises.utimes()` -* `lutimesSync()`, `lutimes()`, `fs.promises.lutimes()` -* `mkdtempSync()`, `mkdtemp()`, `fs.promises.mkdtemp()` -* `lchmod()`, `fs.promises.lchmod()` -* `cpSync()`, `cp()`, `fs.promises.cp()` - -**File descriptor operations** (synchronous and callback): - -* `openSync()`, `open()` -* `closeSync()`, `close()` -* `readSync()`, `read()` -* `writeSync()`, `write()` -* `readvSync()`, `readv()` -* `writevSync()`, `writev()` -* `fstatSync()`, `fstat()` -* `ftruncateSync()`, `ftruncate()` -* `fchmodSync()`, `fchmod()` (no-op for VFS file descriptors) -* `fchownSync()`, `fchown()` (no-op for VFS file descriptors) -* `futimesSync()`, `futimes()` (no-op for VFS file descriptors) -* `fdatasyncSync()`, `fdatasync()` (no-op for VFS file descriptors) -* `fsyncSync()`, `fsync()` (no-op for VFS file descriptors) - -Virtual file descriptors use a bitmask (`0x40000000`) to avoid conflicts with -real file descriptors while remaining valid positive integers. - -**Stream operations**: - -* `createReadStream()` -* `createWriteStream()` - -**Watch operations**: - -* `watch()`, `fs.promises.watch()` -* `watchFile()` -* `unwatchFile()` - -### `node:fs` methods with no VFS equivalent - -The following `node:fs` methods are **not** intercepted and always operate on -the real file system: - -* `glob()`, `globSync()` - -## Integration with module loading - -Virtual files can be loaded as modules using `require()` or `import`: - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); -myVfs.writeFileSync('/math.js', ` - exports.add = (a, b) => a + b; - exports.multiply = (a, b) => a * b; -`); -myVfs.mount('/modules'); - -const math = require('/modules/math.js'); -console.log(math.add(2, 3)); // 5 -``` - -```mjs -import vfs from 'node:vfs'; - -const myVfs = vfs.create(); -myVfs.writeFileSync('/greet.mjs', ` - export default function greet(name) { - return \`Hello, \${name}!\`; - } -`); -myVfs.mount('/modules'); - -const { default: greet } = await import('/modules/greet.mjs'); -console.log(greet('World')); // Hello, World! -``` +The resolved absolute path used as the root. ## Implementation details ### `Stats` objects -The VFS returns real {fs.Stats} objects from `stat()`, `lstat()`, and `fstat()` -operations. These `Stats` objects behave identically to those returned by the real -file system: - -* `stats.isFile()`, `stats.isDirectory()`, `stats.isSymbolicLink()` work correctly -* `stats.size` reflects the actual content size -* `stats.mtime`, `stats.ctime`, `stats.birthtime` are tracked per file -* `stats.mode` includes the file type bits and permissions - -## Use with Single Executable Applications - -When running as a Single Executable Application (SEA) with `"useVfs": true` in -the SEA configuration, bundled assets are automatically mounted at `/sea`. No -additional setup is required. - -`"useVfs"` cannot be used together with `"useSnapshot"`, `"useCodeCache"`, or -`"mainFormat": "module"`. The SEA configuration parser will error if any of -these combinations are detected. - -```cjs -// In your SEA entry script -const fs = require('node:fs'); - -// Access bundled assets directly - they are automatically available at /sea -const config = JSON.parse(fs.readFileSync('/sea/config.json', 'utf8')); -const template = fs.readFileSync('/sea/templates/index.html', 'utf8'); -``` - -See the [Single Executable Applications][] documentation for more information -on creating SEA builds with assets. - -## Symbolic links - -The VFS supports symbolic links within the virtual file system. Symlinks are -created using `vfs.symlinkSync()` or `vfs.promises.symlink()` and can point -to files or directories within the same VFS. - -### Cross-boundary symlinks - -Symbolic links in the VFS are **VFS-internal only**. They cannot: - -* Point from a VFS path to a real file system path -* Point from a real file system path to a VFS path -* Be followed across VFS mount boundaries - -When resolving symlinks, the VFS only follows links that target paths within -the same VFS instance. Attempts to create symlinks with absolute paths that -would resolve outside the VFS are allowed but will result in dangling symlinks. - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); -myVfs.mkdirSync('/data'); -myVfs.writeFileSync('/data/config.json', JSON.stringify({})); - -// This works - symlink within VFS -myVfs.symlinkSync('/data/config.json', '/config'); -myVfs.readFileSync('/config', 'utf8'); // '{}' - -// This creates a dangling symlink - target doesn't exist in VFS -myVfs.symlinkSync('/etc/passwd', '/passwd-link'); -// myVfs.readFileSync('/passwd-link'); // Throws ENOENT -``` - -### Symlinks in overlay mode - -In overlay mode (`{ overlay: true }`), VFS and real file system symlinks remain -completely independent: - -* **VFS symlinks** can only target other VFS paths. A VFS symlink cannot point - to a real file system file, even if that file exists at the same logical path. -* **Real file system symlinks** can only target other real file system paths. - A real symlink cannot point to a VFS file. -* **No cross-layer resolution** occurs. When following a symlink, the resolution - stays entirely within either the VFS layer or the real file system layer. - -```cjs -const vfs = require('node:vfs'); -const fs = require('node:fs'); - -const myVfs = vfs.create({ overlay: true }); -myVfs.mkdirSync('/data'); -myVfs.writeFileSync('/data/config.json', JSON.stringify({ source: 'vfs' })); -myVfs.symlinkSync('/data/config.json', '/data/link'); -myVfs.mount('/app'); - -// VFS symlink resolves within VFS -fs.readFileSync('/app/data/link', 'utf8'); // '{"source": "vfs"}' - -// If /app/data/real-link is a real FS symlink pointing to /app/data/config.json, -// it will NOT resolve to the VFS file - it looks for a real file at that path -``` - -This design ensures predictable behavior: symlinks always resolve within their -own layer, preventing unexpected interactions between virtual and real files. - -## Worker threads - -VFS instances are **not shared across worker threads**. Each worker thread has -its own V8 isolate and module cache, which means: - -* A VFS mounted in the main thread is not accessible from worker threads -* Each worker thread must create and mount its own VFS instance -* VFS data is not synchronized between threads - changes in one thread are not - visible in another - -If you need to share virtual file content with worker threads, you must either: - -1. **Recreate the VFS in each worker** - Pass the data to workers via - `workerData` and have each worker create its own VFS: - -```cjs -const { Worker, isMainThread, workerData } = require('node:worker_threads'); -const vfs = require('node:vfs'); - -if (isMainThread) { - const fileData = { '/config.json': '{"key": "value"}' }; - new Worker(__filename, { workerData: fileData }); -} else { - // Worker: recreate VFS from passed data - const myVfs = vfs.create(); - for (const [path, content] of Object.entries(workerData)) { - myVfs.writeFileSync(path, content); - } - myVfs.mount('/virtual'); - // Now the worker has its own copy of the VFS -} -``` - -2. **Use `RealFSProvider`** - If the data exists on the real file system, use - `RealFSProvider` in each worker to mount the same directory. - -### Using `virtualCwd` in Worker threads - -Since `process.chdir()` is not available in Worker threads, you can use -`RealFSProvider` to enable virtual working directory support: - -```cjs -const { Worker, isMainThread, parentPort } = require('node:worker_threads'); -const vfs = require('node:vfs'); - -if (isMainThread) { - new Worker(__filename); -} else { - // In worker: mount real file system with virtualCwd enabled - const realVfs = vfs.create( - new vfs.RealFSProvider('/home/user/project'), - { virtualCwd: true }, - ); - realVfs.mount('/project'); - - // Now we can use virtual chdir in the worker - realVfs.chdir('/project/src'); - console.log(realVfs.cwd()); // '/project/src' -} -``` - -This limitation exists because implementing cross-thread VFS access would -require moving the implementation to C++ with shared memory management, which -significantly increases complexity. This may be addressed in future versions. - -## Security considerations - -### Path shadowing - -When a VFS is mounted, it **shadows** any real file system paths under the -mount prefix. This means: - -* Real files at the mount path become inaccessible -* All operations are redirected to the VFS -* Modules loaded from shadowed paths will use VFS content - -This behavior can be exploited maliciously. A module could mount a VFS over -critical system paths (like `/etc` on Unix or `C:\Windows` on Windows) and -intercept sensitive operations: - -```cjs -// WARNING: Example of dangerous behavior - DO NOT DO THIS -const vfs = require('node:vfs'); - -const maliciousVfs = vfs.create(); -maliciousVfs.writeFileSync('/passwd', 'malicious content'); -maliciousVfs.mount('/etc'); // Shadows /etc/passwd! - -// Now fs.readFileSync('/etc/passwd') returns 'malicious content' -``` - -### Overlay mode risks - -Overlay mode (`{ overlay: true }`) allows a VFS to selectively intercept file -operations only for paths that exist in the VFS. While this is useful for -mocking specific files in tests, it can also be exploited to covertly intercept -access to specific files: - -```cjs -// WARNING: Example of dangerous behavior - DO NOT DO THIS -const vfs = require('node:vfs'); - -// Create an overlay VFS that intercepts a specific file -const spyVfs = vfs.create(new vfs.MemoryProvider(), { overlay: true }); -spyVfs.writeFileSync('/etc/shadow', 'intercepted!'); -spyVfs.mount('/'); // Mount at root with overlay mode - -// Only /etc/shadow is intercepted, other files work normally -fs.readFileSync('/etc/passwd'); // Real file (works normally) -fs.readFileSync('/etc/shadow'); // Returns 'intercepted!' (mocked) -``` - -This is particularly dangerous because: - -* It is harder to detect than full path shadowing. -* Only specific targeted files are affected. -* Other operations appear to work normally. - -### Recommendations - -* **Audit dependencies**: Be cautious of third-party modules that use VFS, as - they could shadow important paths. -* **Use unique mount points**: Mount VFS at paths that don't conflict with - real file system paths, such as `/@virtual` or `/vfs-{unique-id}`. -* **Verify mount points**: Before trusting file content from paths that could - be shadowed, verify the mount state. -* **Limit VFS usage**: Only use VFS in controlled environments where you trust - all loaded modules. - -[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management -[Security considerations]: #security-considerations -[Single Executable Applications]: single-executable-applications.md +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 From 98f37a8911b14bb3a44841327046d269b66eb61d Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 5 May 2026 18:27:09 +0200 Subject: [PATCH 14/14] vfs: register node:vfs as an experimental builtin in CI metadata Adds 'vfs' to the C++ cannot_be_required list so existing tests (test-code-cache, test-process-get-builtin, test-require-resolve) treat it like other flagged experimental modules. Adds the flag to doc/node.1 and reorders the entry in doc/api/cli.md. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- doc/api/cli.md | 20 ++++++++++---------- doc/node.1 | 7 +++++++ src/node_builtins.cc | 1 + test/parallel/test-process-get-builtin.mjs | 2 ++ test/parallel/test-require-resolve.js | 2 ++ 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/doc/api/cli.md b/doc/api/cli.md index 3b77a2af64b24c..64cfe0f15daf69 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1332,16 +1332,6 @@ added: v25.9.0 Enable the experimental [`node:stream/iter`][] module. -### `--experimental-vfs` - - - -> Stability: 1 - Experimental - -Enable the experimental [`node:vfs`][] module. - ### `--experimental-test-coverage` + +> Stability: 1 - Experimental + +Enable the experimental [`node:vfs`][] module. + ### `--experimental-vm-modules`