From adb398b26702256e11f55a1e8cae026caa5ac780 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 30 Apr 2026 15:33:55 +0200 Subject: [PATCH 01/22] 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 1f766e11454fb6..24c38d0f3a70c8 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 ce6807889e6873ea1ec556c9df815ad50795fec6 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 30 Apr 2026 16:01:40 +0200 Subject: [PATCH 02/22] 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 78cbe8f452a13366055b3487aec8e9bab83c71ed Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 1 May 2026 10:10:47 +0200 Subject: [PATCH 03/22] 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 6f07670038fabff7c569779261ba9fe0c32cb0ac Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 1 May 2026 12:24:09 +0200 Subject: [PATCH 04/22] 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 f44f6fb004ca14cffe7a85ed536273515c0b0562 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 1 May 2026 13:59:47 +0200 Subject: [PATCH 05/22] 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 43c587693221e70abca1b1f0e2b9c91d744d1918 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 1 May 2026 14:57:05 +0200 Subject: [PATCH 06/22] 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 23959316476865082a1cea9d59542704d159ca89 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 2 May 2026 21:58:30 +0200 Subject: [PATCH 07/22] 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 d2c6d4e4116e045b06647243443f77681a7b1749 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 3 May 2026 14:07:16 +0200 Subject: [PATCH 08/22] 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 2d78d3a28783046bf2c703149cab75f8b74a3fb6 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 3 May 2026 19:44:56 +0200 Subject: [PATCH 09/22] 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 bb3beae58286f928d85fe1e0c5acd5e31bd487cc Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 3 May 2026 21:24:42 +0200 Subject: [PATCH 10/22] 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 8f7d6185ac464c..c4003c16bb4fcc 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1372,6 +1372,16 @@ added: 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 bf7f7f74b8deca03fd86dc8f046ab5f3617eca92 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 5 May 2026 18:27:09 +0200 Subject: [PATCH 14/22] 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 c4003c16bb4fcc..c111b9c7e045d9 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1372,16 +1372,6 @@ added: 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` -A `VirtualFileSystem` wraps a [`VirtualProvider`][] and exposes an -`fs`-like API. Each instance maintains its own file tree. +A `VirtualFileSystem` wraps a [`VirtualProvider`][] and exposes a +`node:fs`-like API. Each instance maintains its own file tree. ### `new VirtualFileSystem([provider][, options])` diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index 44e85643972221..4e45a85a12b2fb 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -133,7 +133,14 @@ const schemelessBlockList = new SafeSet([ 'vfs', ]); // Modules that will only be enabled at run time. -const experimentalModuleList = new SafeSet(['ffi', 'sqlite', 'quic', 'stream/iter', 'zlib/iter', 'vfs']); +const experimentalModuleList = new SafeSet([ + 'ffi', + 'quic', + 'sqlite', + 'stream/iter', + 'vfs', + 'zlib/iter', +]); // Set up process.binding() and process._linkedBinding(). { From 37095152b5cc076c4a578c12c525bcae03804190 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 8 May 2026 17:38:35 +0200 Subject: [PATCH 17/22] module: exclude node:vfs from builtinModules when flag is disabled Mirrors the recently-merged handling for node:ffi (see #63158): when --experimental-vfs is not set, node:vfs is filtered out of the public Module.builtinModules list. Adds matching coverage in test-vfs-flag. Refs: https://github.com/nodejs/node/pull/63158 Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- lib/internal/modules/cjs/loader.js | 3 +++ test/parallel/test-vfs-flag.js | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 65a35299eb6552..c18424ee44919d 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -493,6 +493,9 @@ function initializeCJS() { if (!getOptionValue('--experimental-ffi')) { modules = modules.filter((i) => i !== 'node:ffi'); } + if (!getOptionValue('--experimental-vfs')) { + modules = modules.filter((i) => i !== 'node:vfs'); + } Module.builtinModules = ObjectFreeze(modules); initializeCjsConditions(); diff --git a/test/parallel/test-vfs-flag.js b/test/parallel/test-vfs-flag.js index e4ad539624fbed..30b1204beb3a9f 100644 --- a/test/parallel/test-vfs-flag.js +++ b/test/parallel/test-vfs-flag.js @@ -48,3 +48,19 @@ const assert = require('assert'); assert.strictEqual(r.status, 1); assert.match(r.stderr.toString(), /Cannot find module 'vfs'/); } + +// Module.builtinModules reflects whether --experimental-vfs is active. +for (const [flag, expected] of [ + ['--experimental-vfs', 'true\n'], + ['--no-experimental-vfs', 'false\n'], +]) { + const r = spawnSync(process.execPath, [ + flag, + '-p', + 'require("node:module").builtinModules.includes("node:vfs")', + ], { encoding: 'utf8' }); + assert.strictEqual(r.stdout, expected); + assert.strictEqual(r.stderr, ''); + assert.strictEqual(r.status, 0); + assert.strictEqual(r.signal, null); +} From c76d3d335d3874576adf9c3930926db254264ecb Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 15 May 2026 01:15:40 -0700 Subject: [PATCH 18/22] Apply suggestions from code review Co-authored-by: Antoine du Hamel --- doc/api/vfs.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 06dc32039d66dd..974411737ea1ab 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -11,7 +11,7 @@ added: REPLACEME 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 +`node: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 actual file-system. @@ -112,12 +112,12 @@ added: REPLACEME `true` when the underlying provider is read-only. -### File system methods +### APIs `VirtualFileSystem` implements the following methods, with the same signatures as their [`node:fs`][] counterparts: -#### Synchronous methods +#### Synchronous API * `existsSync(path)` * `statSync(path[, options])` @@ -151,14 +151,14 @@ signatures as their [`node:fs`][] counterparts: * Streams: `createReadStream`, `createWriteStream` * Watchers: `watch`, `watchFile`, `unwatchFile` -#### Callback-style asynchronous methods +#### Callback API `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)`. +callback `(err, ...result) => {}`. -#### Promise methods +#### Promise API `vfs.promises` exposes the promise-based variants: @@ -189,7 +189,10 @@ added: REPLACEME 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`, ...). +The base class for all VFS providers. Subclasses implement the essential +primitives (such as `open`, `stat`, `readdir`, `mkdir`, `rmdir`, `unlink`, +`rename`, etc.) and inherit default implementations of the derived +methods (such as `readFile`, `writeFile`, `exists`, `copyFile`, `access`, etc.). ### Capability flags @@ -254,7 +257,7 @@ myVfs.writeFileSync('/x.txt', 'fail'); // throws EROFS added: REPLACEME --> -A provider that wraps a real file system directory and exposes its +A provider that wraps a directory (i.e. one on the actual file system) 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. @@ -265,7 +268,7 @@ outside the root are rejected. added: REPLACEME --> -* `rootPath` {string} The absolute file system path to use as the root. +* `rootPath` {string} The absolute file-system path to use as the root. Must be a non-empty string. ```cjs From 939365af803f5eb4c6639a195d114f32819380fe Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 15 May 2026 14:33:41 +0200 Subject: [PATCH 19/22] vfs: use posix paths on windows --- lib/internal/vfs/file_system.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index 1b84787f2318fd..c48478ee85aa6c 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -8,8 +8,8 @@ const { const { validateBoolean } = require('internal/validators'); const { MemoryProvider } = require('internal/vfs/providers/memory'); -const path = require('path'); -const { join: joinPath } = path; +const { posix: pathPosix } = require('path'); +const { join: joinPath } = pathPosix; const { openVirtualFd, getVirtualFd, @@ -89,7 +89,7 @@ class VirtualFileSystem { * @returns {string} */ #toProviderPath(inputPath) { - return path.posix.normalize(inputPath); + return pathPosix.normalize(inputPath); } // ==================== FS Operations (Sync) ==================== From 56589c896d85ba65bdf4260a10fd20d9da819d27 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 15 May 2026 06:52:54 -0700 Subject: [PATCH 20/22] Apply suggestions from code review Co-authored-by: Antoine du Hamel --- lib/internal/vfs/dir.js | 11 +++-------- lib/internal/vfs/file_handle.js | 7 +++---- test/parallel/test-vfs-memory-provider-dynamic.js | 4 ++-- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/lib/internal/vfs/dir.js b/lib/internal/vfs/dir.js index ff428bcd2fb20f..803aeb4045310d 100644 --- a/lib/internal/vfs/dir.js +++ b/lib/internal/vfs/dir.js @@ -89,14 +89,6 @@ class VirtualDir { } } - [SymbolAsyncIterator]() { - return this.entries(); - } - - [SymbolAsyncDispose]() { - return this.close(); - } - [SymbolDispose]() { if (!this.#closed) { this.closeSync(); @@ -104,6 +96,9 @@ class VirtualDir { } } +VirtualDir.prototype[SymbolAsyncIterator] = VirtualDir.prototype.entries; +VirtualDir.prototype[SymbolAsyncDispose] = VirtualDir.prototype.close; + module.exports = { VirtualDir, }; diff --git a/lib/internal/vfs/file_handle.js b/lib/internal/vfs/file_handle.js index c64e340e2a4f96..fc52a5aed8d034 100644 --- a/lib/internal/vfs/file_handle.js +++ b/lib/internal/vfs/file_handle.js @@ -346,12 +346,11 @@ class VirtualFileHandle { closeSync() { this[kClosed] = true; } - - [SymbolAsyncDispose]() { - return this.close(); - } } +VirtualFileHandle.prototype[SymbolAsyncDispose] = VirtualFileHandle.prototype.close; +VirtualFileHandle.prototype[SymbolDispose] = VirtualFileHandle.prototype.closeSync; + /** * A file handle for in-memory file content. * Used by MemoryProvider and similar providers. diff --git a/test/parallel/test-vfs-memory-provider-dynamic.js b/test/parallel/test-vfs-memory-provider-dynamic.js index 40b4c5d9fca32a..759407c244e2c3 100644 --- a/test/parallel/test-vfs-memory-provider-dynamic.js +++ b/test/parallel/test-vfs-memory-provider-dynamic.js @@ -51,8 +51,8 @@ function makeFileEntry(prototypeFrom, contentProvider) { const provider = new MemoryProvider(); const root = getRoot(provider); - const dir = { __proto__: Object.getPrototypeOf(root) }; - Object.assign(dir, { + const dir = { + __proto__: Object.getPrototypeOf(root), type: 1, // TYPE_DIR mode: 0o755, children: new Map(), From e947d94d7c5ff98560cb82da77749ba6d9cbcf9c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 15 May 2026 07:06:28 -0700 Subject: [PATCH 21/22] Apply suggestions from code review Co-authored-by: Antoine du Hamel --- lib/internal/vfs/providers/real.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js index 32d7769859693c..e0eef2132bef93 100644 --- a/lib/internal/vfs/providers/real.js +++ b/lib/internal/vfs/providers/real.js @@ -169,13 +169,10 @@ class RealFSProvider extends VirtualProvider { */ 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 }); + this.#rootPath = path.resolve(getValidatedPath(rootPath, 'rootPath')); + setOwnProperty(this, 'readonly', false); + setOwnProperty(this, 'supportsSymlinks', true); } /** From 1f47d56f79432005956612fdaef74d2b4ca1e961 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 15 May 2026 16:09:38 +0200 Subject: [PATCH 22/22] vfs: drop RealFileHandle.fd getter The getter exposed the raw real-fs file descriptor of a virtual file handle, which is a leaky abstraction: it lets user code bypass the VFS layer. The only consumer was a test that closed the real fd to trigger the EBADF rejection paths in the async fd ops; those branches are defensive and unreachable from the public API, so the test block is dropped along with the getter. Assisted-by: Claude-Opus4.7 Signed-off-by: Matteo Collina --- lib/internal/vfs/providers/real.js | 8 -------- test/parallel/test-vfs-real-provider-handle.js | 16 ---------------- 2 files changed, 24 deletions(-) diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js index e0eef2132bef93..7e7a15c26a0c68 100644 --- a/lib/internal/vfs/providers/real.js +++ b/lib/internal/vfs/providers/real.js @@ -47,14 +47,6 @@ class RealFileHandle extends VirtualFileHandle { 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); diff --git a/test/parallel/test-vfs-real-provider-handle.js b/test/parallel/test-vfs-real-provider-handle.js index 1da455606695de..5246e28e3206c5 100644 --- a/test/parallel/test-vfs-real-provider-handle.js +++ b/test/parallel/test-vfs-real-provider-handle.js @@ -44,7 +44,6 @@ const myVfs = vfs.create(new vfs.RealFSProvider(root)); { 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); @@ -102,19 +101,4 @@ const myVfs = vfs.create(new vfs.RealFSProvider(root)); 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());