diff --git a/doc/api/fs.md b/doc/api/fs.md index 6ea9fa9fdde0f2..8a2e405dc852ac 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -8293,6 +8293,596 @@ The following constants are meant for use with the {fs.Stats} object's On Windows, only `S_IRUSR` and `S_IWUSR` are available. +## Virtual file system + + + +> Stability: 1 - Experimental + +The virtual file system (VFS) allows creating in-memory file system overlays +that integrate seamlessly with the Node.js `fs` module and module loader. Virtual +files and directories can be accessed using standard `fs` operations and can be +`require()`d or `import`ed like regular files. + +### Creating a virtual file system + +Use `fs.createVirtual()` to create a new VFS instance: + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); + +// Add files to the VFS +vfs.addFile('/config.json', JSON.stringify({ debug: true })); +vfs.addFile('/data.txt', 'Hello, World!'); + +// Mount the VFS at a specific path +vfs.mount('/app'); + +// Now files are accessible via standard fs APIs +const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8')); +console.log(config.debug); // true +``` + +```mjs +import fs from 'node:fs'; + +const vfs = fs.createVirtual(); + +// Add files to the VFS +vfs.addFile('/config.json', JSON.stringify({ debug: true })); +vfs.addFile('/data.txt', 'Hello, World!'); + +// Mount the VFS at a specific path +vfs.mount('/app'); + +// Now files are accessible via standard fs APIs +const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8')); +console.log(config.debug); // true +``` + +### `fs.createVirtual([options])` + + + +* `options` {Object} + * `fallthrough` {boolean} When `true`, operations on paths not in the VFS + fall through to the real file system. **Default:** `true`. + * `moduleHooks` {boolean} When `true`, enables hooks for `require()` and + `import` to load modules from the VFS. **Default:** `true`. + * `virtualCwd` {boolean} When `true`, enables virtual working directory + support via `vfs.chdir()` and `vfs.cwd()`. **Default:** `false`. +* Returns: {VirtualFileSystem} + +Creates a new virtual file system instance. + +```cjs +const fs = require('node:fs'); + +// Create a VFS that falls through to real fs for unmatched paths +const vfs = fs.createVirtual({ fallthrough: true }); + +// Create a VFS that only serves virtual files +const isolatedVfs = fs.createVirtual({ fallthrough: false }); + +// Create a VFS without module loading hooks (fs operations only) +const fsOnlyVfs = fs.createVirtual({ moduleHooks: false }); +``` + +### Class: `VirtualFileSystem` + + + +A `VirtualFileSystem` instance manages virtual files and directories and +provides methods to mount them into the file system namespace. + +#### `vfs.addFile(path, content)` + + + +* `path` {string} The virtual path for the file. +* `content` {string|Buffer|Function} The file content, or a function that + returns the content. + +Adds a virtual file. The `content` can be: + +* A `string` or `Buffer` for static content +* A synchronous function `() => string|Buffer` for dynamic content +* An async function `async () => string|Buffer` for async dynamic content + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); + +// Static content +vfs.addFile('/config.json', '{"debug": true}'); + +// Dynamic content (evaluated on each read) +vfs.addFile('/timestamp.txt', () => Date.now().toString()); + +// Async dynamic content +vfs.addFile('/data.json', async () => { + const data = await fetchData(); + return JSON.stringify(data); +}); +``` + +#### `vfs.addDirectory(path[, populate])` + + + +* `path` {string} The virtual path for the directory. +* `populate` {Function} Optional callback to dynamically populate the directory. + +Adds a virtual directory. If `populate` is provided, it receives a scoped VFS +for adding files and subdirectories within this directory. The callback is +invoked lazily on first access. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); + +// Empty directory +vfs.addDirectory('/empty'); + +// Directory with static contents +vfs.addDirectory('/lib'); +vfs.addFile('/lib/utils.js', 'module.exports = {}'); + +// Dynamic directory (populated on first access) +vfs.addDirectory('/plugins', (dir) => { + dir.addFile('a.js', 'module.exports = "plugin a"'); + dir.addFile('b.js', 'module.exports = "plugin b"'); +}); +``` + +#### `vfs.mount(prefix)` + + + +* `prefix` {string} The path prefix where the VFS will be mounted. + +Mounts the VFS at a specific path prefix. All paths in the VFS become accessible +under this prefix. Only one mount point can be active at a time. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/module.js', 'module.exports = "hello"'); +vfs.mount('/virtual'); + +// Now accessible at /virtual/module.js +const content = fs.readFileSync('/virtual/module.js', 'utf8'); +const mod = require('/virtual/module.js'); +``` + +#### `vfs.overlay()` + + + +Enables overlay mode, where the VFS is checked first for all file system +operations. If a path exists in the VFS, it is used; otherwise, the operation +falls through to the real file system (if `fallthrough` is enabled). + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/etc/myapp/config.json', '{"virtual": true}'); +vfs.overlay(); + +// Virtual file is returned +fs.readFileSync('/etc/myapp/config.json', 'utf8'); // '{"virtual": true}' + +// Real file system used for non-virtual paths +fs.readFileSync('/etc/hosts', 'utf8'); // Real file contents +``` + +#### `vfs.unmount()` + + + +Unmounts the VFS, removing it from the file system namespace. After unmounting, +the virtual files are no longer accessible through standard `fs` operations. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/test.txt', 'content'); +vfs.mount('/vfs'); + +fs.existsSync('/vfs/test.txt'); // true + +vfs.unmount(); + +fs.existsSync('/vfs/test.txt'); // false +``` + +#### `vfs.has(path)` + + + +* `path` {string} The path to check. +* Returns: {boolean} + +Returns `true` if the VFS contains a file or directory at the given path. + +#### `vfs.remove(path)` + + + +* `path` {string} The path to remove. +* Returns: {boolean} `true` if the entry was removed, `false` if not found. + +Removes a file or directory from the VFS. + +#### `vfs.virtualCwdEnabled` + + + +* {boolean} + +Returns `true` if virtual working directory support is enabled for this VFS +instance. This is determined by the `virtualCwd` option passed to +`fs.createVirtual()`. + +#### `vfs.cwd()` + + + +* Returns: {string|null} The current virtual working directory, or `null` if + not set. + +Gets the virtual current working directory. Throws `ERR_INVALID_STATE` if +`virtualCwd` option was not enabled when creating the VFS. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual({ virtualCwd: true }); +vfs.addDirectory('/project'); +vfs.mount('/app'); + +console.log(vfs.cwd()); // null (not set yet) + +vfs.chdir('/app/project'); +console.log(vfs.cwd()); // '/app/project' +``` + +#### `vfs.chdir(path)` + + + +* `path` {string} The directory path to set as the current working directory. + +Sets the virtual current working directory. The path must exist in the VFS and +must be a directory. Throws `ENOENT` if the path does not exist, `ENOTDIR` if +the path is not a directory, or `ERR_INVALID_STATE` if `virtualCwd` option was +not enabled. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual({ virtualCwd: true }); +vfs.addDirectory('/project'); +vfs.addDirectory('/project/src'); +vfs.addFile('/project/src/index.js', 'module.exports = "hello";'); +vfs.mount('/app'); + +vfs.chdir('/app/project'); +console.log(vfs.cwd()); // '/app/project' + +vfs.chdir('/app/project/src'); +console.log(vfs.cwd()); // '/app/project/src' +``` + +##### `process.chdir()` and `process.cwd()` interception + +When `virtualCwd` is enabled and the VFS is mounted or in overlay mode, +`process.chdir()` and `process.cwd()` are intercepted to support transparent +virtual working directory operations: + +* `process.chdir(path)` - When called with a path that resolves to the VFS, + the virtual cwd is updated instead of changing the real process working + directory. Paths outside the VFS fall through to the real `process.chdir()`. + +* `process.cwd()` - When a virtual cwd is set, returns the virtual cwd. + Otherwise, returns the real process working directory. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual({ virtualCwd: true }); +vfs.addDirectory('/project'); +vfs.mount('/virtual'); + +const originalCwd = process.cwd(); + +// Change to a VFS directory using process.chdir +process.chdir('/virtual/project'); +console.log(process.cwd()); // '/virtual/project' +console.log(vfs.cwd()); // '/virtual/project' + +// Change to a real directory (falls through) +process.chdir('/tmp'); +console.log(process.cwd()); // '/tmp' (real cwd) + +// Restore and unmount +process.chdir(originalCwd); +vfs.unmount(); +``` + +When the VFS is unmounted, `process.chdir()` and `process.cwd()` are restored +to their original implementations. + +> **Note:** VFS hooks are not automatically shared with worker threads. Each +> worker thread has its own `process` object and must set up its own VFS +> instance if virtual cwd support is needed. + +#### `vfs.resolvePath(path)` + + + +* `path` {string} The path to resolve. +* Returns: {string} The resolved absolute path. + +Resolves a path relative to the virtual current working directory. If the path +is absolute, it is returned as-is (normalized). If `virtualCwd` is enabled and +a virtual cwd is set, relative paths are resolved against it. Otherwise, +relative paths are resolved using the real process working directory. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual({ virtualCwd: true }); +vfs.addDirectory('/project'); +vfs.addDirectory('/project/src'); +vfs.mount('/app'); + +vfs.chdir('/app/project'); + +// Absolute paths returned as-is +console.log(vfs.resolvePath('/other/path')); // '/other/path' + +// Relative paths resolved against virtual cwd +console.log(vfs.resolvePath('src/index.js')); // '/app/project/src/index.js' +console.log(vfs.resolvePath('./src/index.js')); // '/app/project/src/index.js' +``` + +### VFS file system operations + +The `VirtualFileSystem` instance provides direct access to file system +operations that bypass the real file system entirely. These methods have the +same signatures as their `fs` module counterparts. + +#### Synchronous methods + +* `vfs.readFileSync(path[, options])` - Read file contents +* `vfs.statSync(path[, options])` - Get file stats +* `vfs.lstatSync(path[, options])` - Get file stats (same as statSync for VFS) +* `vfs.readdirSync(path[, options])` - List directory contents +* `vfs.existsSync(path)` - Check if path exists +* `vfs.realpathSync(path[, options])` - Resolve path (normalizes `.` and `..`) +* `vfs.accessSync(path[, mode])` - Check file accessibility +* `vfs.openSync(path[, flags[, mode]])` - Open file and return file descriptor +* `vfs.closeSync(fd)` - Close file descriptor +* `vfs.readSync(fd, buffer, offset, length, position)` - Read from file descriptor +* `vfs.fstatSync(fd[, options])` - Get stats from file descriptor + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/data.txt', 'Hello, World!'); + +// Direct VFS operations (no mounting required) +const content = vfs.readFileSync('/data.txt', 'utf8'); +const stats = vfs.statSync('/data.txt'); +console.log(content); // 'Hello, World!' +console.log(stats.size); // 13 +``` + +#### Callback methods + +* `vfs.readFile(path[, options], callback)` - Read file contents +* `vfs.stat(path[, options], callback)` - Get file stats +* `vfs.lstat(path[, options], callback)` - Get file stats +* `vfs.readdir(path[, options], callback)` - List directory contents +* `vfs.realpath(path[, options], callback)` - Resolve path +* `vfs.access(path[, mode], callback)` - Check file accessibility +* `vfs.open(path[, flags[, mode]], callback)` - Open file +* `vfs.close(fd, callback)` - Close file descriptor +* `vfs.read(fd, buffer, offset, length, position, callback)` - Read from fd +* `vfs.fstat(fd[, options], callback)` - Get stats from file descriptor + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/async.txt', 'Async content'); + +vfs.readFile('/async.txt', 'utf8', (err, data) => { + if (err) throw err; + console.log(data); // 'Async content' +}); +``` + +#### Promise methods + +The `vfs.promises` object provides promise-based versions of the file system +methods: + +* `vfs.promises.readFile(path[, options])` - Read file contents +* `vfs.promises.stat(path[, options])` - Get file stats +* `vfs.promises.lstat(path[, options])` - Get file stats +* `vfs.promises.readdir(path[, options])` - List directory contents +* `vfs.promises.realpath(path[, options])` - Resolve path +* `vfs.promises.access(path[, mode])` - Check file accessibility + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/promise.txt', 'Promise content'); + +(async () => { + const data = await vfs.promises.readFile('/promise.txt', 'utf8'); + console.log(data); // 'Promise content' +})(); +``` + +#### Streams + +* `vfs.createReadStream(path[, options])` - Create a readable stream + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/stream.txt', 'Streaming content'); + +const stream = vfs.createReadStream('/stream.txt', { encoding: 'utf8' }); +stream.on('data', (chunk) => console.log(chunk)); +stream.on('end', () => console.log('Done')); +``` + +The readable stream supports the following options: + +* `encoding` {string} Character encoding for string output. +* `start` {integer} Byte position to start reading from. +* `end` {integer} Byte position to stop reading at (inclusive). +* `highWaterMark` {integer} Maximum number of bytes to buffer. +* `autoClose` {boolean} Automatically close the stream on end. **Default:** `true`. + +### Module loading from VFS + +Virtual files can be loaded as modules using `require()` or `import`. The VFS +integrates with the Node.js module loaders automatically when mounted or in +overlay mode. + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); + +// Add a CommonJS module +vfs.addFile('/app/math.js', ` + module.exports = { + add: (a, b) => a + b, + multiply: (a, b) => a * b + }; +`); + +// Add a package.json +vfs.addFile('/app/package.json', '{"name": "virtual-app", "main": "math.js"}'); + +vfs.mount('/app'); + +// Require the virtual module +const math = require('/app/math.js'); +console.log(math.add(2, 3)); // 5 + +// Require the package +const pkg = require('/app'); +console.log(pkg.multiply(4, 5)); // 20 +``` + +```mjs +import fs from 'node:fs'; + +const vfs = fs.createVirtual(); + +// Add an ES module +vfs.addFile('/esm/module.mjs', ` + export const value = 42; + export default function greet() { return 'Hello'; } +`); + +vfs.mount('/esm'); + +// Dynamic import of virtual ES module +const mod = await import('/esm/module.mjs'); +console.log(mod.value); // 42 +console.log(mod.default()); // 'Hello' +``` + +### Glob support + +The VFS integrates with `fs.glob()`, `fs.globSync()`, and `fs/promises.glob()` +when mounted or in overlay mode: + +```cjs +const fs = require('node:fs'); + +const vfs = fs.createVirtual(); +vfs.addFile('/src/index.js', 'export default 1;'); +vfs.addFile('/src/utils.js', 'export const util = 1;'); +vfs.addFile('/src/lib/helper.js', 'export const helper = 1;'); +vfs.mount('/virtual'); + +// Sync glob +const files = fs.globSync('/virtual/src/**/*.js'); +console.log(files); +// ['/virtual/src/index.js', '/virtual/src/utils.js', '/virtual/src/lib/helper.js'] + +// Async glob with callback +fs.glob('/virtual/src/*.js', (err, matches) => { + console.log(matches); // ['/virtual/src/index.js', '/virtual/src/utils.js'] +}); + +// Async glob with promises (returns async iterator) +const { glob } = require('node:fs/promises'); +(async () => { + for await (const file of glob('/virtual/src/**/*.js')) { + console.log(file); + } +})(); +``` + +### Limitations + +The current VFS implementation has the following limitations: + +* **Read-only**: Files can only be set via `addFile()`. Write operations + (`writeFile`, `appendFile`, etc.) are not supported. +* **No file watching**: `fs.watch()` and `fs.watchFile()` do not work with + virtual files. +* **No real file descriptor**: Virtual file descriptors (10000+) are managed + separately from real file descriptors. + ## Notes ### Ordering of callback and promise-based operations diff --git a/doc/api/index.md b/doc/api/index.md index 8db8b8c8806274..d7a04fcea1ef41 100644 --- a/doc/api/index.md +++ b/doc/api/index.md @@ -66,6 +66,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/single-executable-applications.md b/doc/api/single-executable-applications.md index 611700a7a4bf1e..5361a80ae5bf99 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -174,6 +174,90 @@ const raw = getRawAsset('a.jpg'); See documentation of the [`sea.getAsset()`][], [`sea.getAssetAsBlob()`][], [`sea.getRawAsset()`][] and [`sea.getAssetKeys()`][] APIs for more information. +### Virtual File System (VFS) for assets + +> Stability: 1 - Experimental + +Instead of using the `node:sea` API to access individual assets, you can use +the Virtual File System (VFS) to access bundled assets through standard `fs` +APIs. The VFS automatically populates itself with all assets defined in the +SEA configuration and mounts them at a virtual path (default: `/sea`). + +To use the VFS with SEA: + +```cjs +const fs = require('node:fs'); +const sea = require('node:sea'); + +// Check if SEA assets are available +if (sea.hasAssets()) { + // Initialize and mount the SEA VFS + const vfs = sea.getVfs(); + + // Now you can use standard fs APIs to read bundled assets + const config = JSON.parse(fs.readFileSync('/sea/config.json', 'utf8')); + const data = fs.readFileSync('/sea/data/file.txt'); + + // Directory operations work too + const files = fs.readdirSync('/sea/assets'); + + // Check if a bundled file exists + if (fs.existsSync('/sea/optional.json')) { + // ... + } +} +``` + +The VFS supports the following `fs` operations on bundled assets: + +* `readFileSync()` / `readFile()` / `promises.readFile()` +* `statSync()` / `stat()` / `promises.stat()` +* `lstatSync()` / `lstat()` / `promises.lstat()` +* `readdirSync()` / `readdir()` / `promises.readdir()` +* `existsSync()` +* `realpathSync()` / `realpath()` / `promises.realpath()` +* `accessSync()` / `access()` / `promises.access()` +* `openSync()` / `open()` - for reading +* `createReadStream()` + +#### Loading modules from VFS in SEA + +Once the VFS is initialized with `sea.getVfs()`, you can use `require()` directly +with absolute VFS paths: + +```cjs +const sea = require('node:sea'); + +// Initialize VFS - this must be called first +sea.getVfs(); + +// Now you can require bundled modules directly +const myModule = require('/sea/lib/mymodule.js'); +const utils = require('/sea/utils/helpers.js'); +``` + +The SEA's `require()` function automatically detects VFS paths (paths starting +with the VFS mount point, e.g., `/sea/`) and loads modules from the virtual +file system. + +#### Custom mount prefix + +By default, the VFS is mounted at `/sea`. You can specify a custom prefix +when initializing the VFS: + +```cjs +const fs = require('node:fs'); +const sea = require('node:sea'); + +const vfs = sea.getSeaVfs({ prefix: '/app' }); + +// Assets are now accessible under /app +const config = fs.readFileSync('/app/config.json', 'utf8'); +``` + +Note: `sea.getVfs()` returns a singleton. The `prefix` option is only used +on the first call; subsequent calls return the same cached instance. + ### Startup snapshot support The `useSnapshot` field can be used to enable startup snapshot support. In this diff --git a/doc/api/test.md b/doc/api/test.md index 927208af853d38..4fda764b3b0ddb 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -2334,6 +2334,94 @@ test('mocks a counting function', (t) => { }); ``` +### `mock.fs([options])` + + + +> Stability: 1.0 - Early development + +* `options` {Object} Optional configuration options for the mock file system. + The following properties are supported: + * `prefix` {string} The mount point prefix for the virtual file system. + **Default:** `'/mock'`. + * `files` {Object} An optional object where keys are file paths (relative to + the VFS root) and values are the file contents. Contents can be strings, + Buffers, or functions that return strings/Buffers. +* Returns: {MockFSContext} An object that can be used to manage the mock file + system. + +This function creates a mock file system using the Virtual File System (VFS). +The mock file system is automatically cleaned up when the test completes. + +## Class: `MockFSContext` + +The `MockFSContext` object is returned by `mock.fs()` and provides the +following methods and properties: + +* `vfs` {VirtualFileSystem} The underlying VFS instance. +* `prefix` {string} The mount prefix. +* `addFile(path, content)` Adds a file to the mock file system. +* `addDirectory(path[, populate])` Adds a directory to the mock file system. +* `existsSync(path)` Checks if a path exists (path is relative to prefix). +* `restore()` Manually restores the file system to its original state. + +The following example demonstrates how to create a mock file system for testing: + +```js +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); + +test('reads configuration from mock file', (t) => { + const mockFs = t.mock.fs({ + prefix: '/app', + files: { + '/config.json': JSON.stringify({ debug: true }), + '/data/users.txt': 'user1\nuser2\nuser3', + }, + }); + + // Files are accessible via standard fs APIs + const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8')); + assert.strictEqual(config.debug, true); + + // Check file existence + assert.strictEqual(fs.existsSync('/app/config.json'), true); + assert.strictEqual(fs.existsSync('/app/missing.txt'), false); + + // Use mockFs.existsSync for paths relative to prefix + assert.strictEqual(mockFs.existsSync('/config.json'), true); +}); + +test('supports dynamic file content', (t) => { + let counter = 0; + const mockFs = t.mock.fs({ prefix: '/dynamic' }); + + mockFs.addFile('/counter.txt', () => { + counter++; + return String(counter); + }); + + // Each read calls the function + assert.strictEqual(fs.readFileSync('/dynamic/counter.txt', 'utf8'), '1'); + assert.strictEqual(fs.readFileSync('/dynamic/counter.txt', 'utf8'), '2'); +}); + +test('supports require from mock files', (t) => { + t.mock.fs({ + prefix: '/modules', + files: { + '/math.js': 'module.exports = { add: (a, b) => a + b };', + }, + }); + + const math = require('/modules/math.js'); + assert.strictEqual(math.add(2, 3), 5); +}); +``` + ### `mock.getter(object, methodName[, implementation][, options])` + + + +> 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 `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 `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 + +## Mount mode + +The VFS operates in **mount mode only**. When mounted at a path prefix (e.g., +`/virtual`), the VFS handles all operations for paths starting with that +prefix. There is no overlay mode that would merge virtual and real file system +contents at the same paths. + +## Basic usage + +The following example shows how to create a virtual file system, add files, +and access them through the standard `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(); +``` + +## `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 `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. + +### `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.mount(prefix)` + + + +* `prefix` {string} The path prefix where the VFS will be mounted. + +Mounts the virtual file system at the specified path prefix. After mounting, +files in the VFS can be accessed via the `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' +``` + +### `vfs.unmount()` + + + +Unmounts the virtual file system. After unmounting, virtual files are no longer +accessible through the `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. + +### `vfs.mounted` + + + +* {boolean} + +Returns `true` if the VFS is currently 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.mountPoint` + + + +* {string | null} + +The current mount point, or `null` if not mounted. + +### `vfs.readonly` + + + +* {boolean} + +Returns `true` if the underlying provider is read-only. + +### `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. + +### File System Methods + +The `VirtualFileSystem` class provides methods that mirror the `fs` module API. +All paths are relative to the VFS root (not the mount point). + +#### Synchronous Methods + +* `vfs.accessSync(path[, mode])` - Check file accessibility +* `vfs.appendFileSync(path, data[, options])` - Append to a file +* `vfs.closeSync(fd)` - Close a file descriptor +* `vfs.copyFileSync(src, dest[, mode])` - Copy a file +* `vfs.existsSync(path)` - Check if path exists +* `vfs.lstatSync(path[, options])` - Get file stats (no symlink follow) +* `vfs.mkdirSync(path[, options])` - Create a directory +* `vfs.openSync(path, flags[, mode])` - Open a file +* `vfs.readFileSync(path[, options])` - Read a file +* `vfs.readSync(fd, buffer, offset, length, position)` - Read from fd +* `vfs.readlinkSync(path[, options])` - Read symlink target +* `vfs.readdirSync(path[, options])` - Read directory contents +* `vfs.realpathSync(path[, options])` - Resolve symlinks +* `vfs.renameSync(oldPath, newPath)` - Rename a file or directory +* `vfs.rmdirSync(path)` - Remove a directory +* `vfs.statSync(path[, options])` - Get file stats +* `vfs.symlinkSync(target, path[, type])` - Create a symlink +* `vfs.unlinkSync(path)` - Remove a file +* `vfs.writeFileSync(path, data[, options])` - Write a file +* `vfs.writeSync(fd, buffer, offset, length, position)` - Write to fd + +#### 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. + +### Properties + +#### `provider.readonly` + + + +* {boolean} + +Returns `true` if the provider is read-only. + +#### `provider.supportsSymlinks` + + + +* {boolean} + +Returns `true` if the provider supports symbolic links. + +### 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: `SEAProvider` + + + +The `SEAProvider` provides read-only access to assets bundled in a Single +Executable Application (SEA). It can only be used when running as a SEA. + +```cjs +const { create, SEAProvider } = require('node:vfs'); + +// Only works in SEA builds +try { + const seaVfs = create(new SEAProvider()); + seaVfs.mount('/assets'); +} catch (err) { + console.log('Not running as SEA'); +} +``` + +## 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'); +``` + +### 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' +} +``` + +### `realFSProvider.rootPath` + + + +* {string} + +The real file system path that this provider wraps. + +## Integration with `fs` module + +When a VFS is mounted, the standard `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 +``` + +## 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 + +### File descriptors + +Virtual file descriptors start at 10000 to avoid conflicts with real operating +system file descriptors. This allows the VFS to coexist with real file system +operations without file descriptor collisions. + +## Use with Single Executable Applications + +When running as a Single Executable Application (SEA), bundled assets are +automatically mounted at `/sea`. No additional setup is required: + +```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', '{}'); + +// 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 +``` + +## 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's 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. + +[Security considerations]: #security-considerations +[Single Executable Applications]: single-executable-applications.md diff --git a/lib/fs.js b/lib/fs.js index 66bf3a81aec56d..8e7bf9804a7082 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -3207,6 +3207,19 @@ function globSync(pattern, options) { return new Glob(pattern, options).globSync(); } +const lazyVfs = getLazy(() => require('internal/vfs/file_system').VirtualFileSystem); + +/** + * Creates a new virtual file system instance. + * @param {object} [options] Configuration options + * @param {boolean} [options.fallthrough] Whether to fall through to real fs on miss + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks + * @returns {VirtualFileSystem} + */ +function createVirtual(options) { + const VirtualFileSystem = lazyVfs(); + return new VirtualFileSystem(options); +} module.exports = fs = { appendFile, @@ -3224,6 +3237,7 @@ module.exports = fs = { cp, cpSync, createReadStream, + createVirtual, createWriteStream, exists, existsSync, diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index f49f0814bbc687..6e25416d6db5ef 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -129,6 +129,7 @@ const schemelessBlockList = new SafeSet([ 'quic', 'test', 'test/reporters', + 'vfs', ]); // Modules that will only be enabled at run time. const experimentalModuleList = new SafeSet(['sqlite', 'quic']); diff --git a/lib/internal/main/embedding.js b/lib/internal/main/embedding.js index 105f4a7b6da777..fd84a8d5d8ee1f 100644 --- a/lib/internal/main/embedding.js +++ b/lib/internal/main/embedding.js @@ -97,22 +97,70 @@ function embedderRunCjs(content) { let warnedAboutBuiltins = false; +// Lazy-loaded SEA VFS support +let seaVfsInitialized = false; +let seaVfs = null; +let seaVfsMountPoint = null; + +function initSeaVfs() { + if (seaVfsInitialized) return; + seaVfsInitialized = true; + + if (!isLoadingSea) return; + + // Check if SEA has assets (VFS support) + const { hasSeaAssets, getSeaVfs } = require('internal/vfs/sea'); + if (hasSeaAssets()) { + seaVfs = getSeaVfs(); + if (seaVfs) { + seaVfsMountPoint = seaVfs.mountPoint; + } + } +} + function embedderRequire(id) { const normalizedId = normalizeRequirableId(id); - if (!normalizedId) { - if (isBuiltinWarningNeeded && !warnedAboutBuiltins) { - emitWarningSync( - 'Currently the require() provided to the main script embedded into ' + - 'single-executable applications only supports loading built-in modules.\n' + - 'To load a module from disk after the single executable application is ' + - 'launched, use require("module").createRequire().\n' + - 'Support for bundled module loading or virtual file systems are under ' + - 'discussions in https://github.com/nodejs/single-executable'); - warnedAboutBuiltins = true; + if (normalizedId) { + // Built-in module + return require(normalizedId); + } + + // Not a built-in module - check if it's a VFS path in SEA + if (isLoadingSea) { + initSeaVfs(); + + if (seaVfs && seaVfsMountPoint) { + // Check if the path is within the VFS mount point + // Support both absolute paths (/sea/...) and relative to mount point + let modulePath = id; + if (id.startsWith(seaVfsMountPoint) || id.startsWith('/')) { + // Absolute path - resolve within VFS + if (!id.startsWith(seaVfsMountPoint) && id.startsWith('/')) { + // Path like '/modules/foo.js' - prepend mount point + modulePath = seaVfsMountPoint + id; + } + + // Check if the file exists in VFS + if (seaVfs.existsSync(modulePath)) { + // Use Module._load to load the module, which will use VFS hooks + return Module._load(modulePath, embedderRequire.main, false); + } + } } - throw new ERR_UNKNOWN_BUILTIN_MODULE(id); } - return require(normalizedId); + + // No VFS or file not in VFS - show warning and throw + if (isBuiltinWarningNeeded && !warnedAboutBuiltins) { + emitWarningSync( + 'Currently the require() provided to the main script embedded into ' + + 'single-executable applications only supports loading built-in modules.\n' + + 'To load a module from disk after the single executable application is ' + + 'launched, use require("module").createRequire().\n' + + 'Support for bundled module loading or virtual file systems are under ' + + 'discussions in https://github.com/nodejs/single-executable'); + warnedAboutBuiltins = true; + } + throw new ERR_UNKNOWN_BUILTIN_MODULE(id); } return [process, embedderRequire, embedderRunCjs]; diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index 1af24c77a10731..84c2a7e420b262 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -34,6 +34,7 @@ const { } = require('internal/url'); const { emitExperimentalWarning, + getLazy, getStructuredStack, kEmptyObject, } = require('internal/util'); @@ -50,6 +51,12 @@ const { const { MockTimers } = require('internal/test_runner/mock/mock_timers'); const { Module } = require('internal/modules/cjs/loader'); const { _load, _nodeModulePaths, _resolveFilename, isBuiltin } = Module; +const { dirname, join } = require('path'); + +// Lazy-load VirtualFileSystem to avoid loading VFS code if fs mocking is not used +const lazyVirtualFileSystem = getLazy( + () => require('internal/vfs/file_system').VirtualFileSystem, +); function kDefaultFunction() {} const enableModuleMocking = getOptionValue('--experimental-test-module-mocks'); const kSupportedFormats = [ @@ -402,6 +409,76 @@ class MockPropertyContext { const { restore: restoreProperty } = MockPropertyContext.prototype; +/** + * Context for mocking the file system using VFS. + */ +class MockFSContext { + #vfs; + #prefix; + + constructor(vfs, prefix) { + this.#vfs = vfs; + this.#prefix = prefix; + } + + /** + * Gets the underlying VirtualFileSystem instance. + * @returns {VirtualFileSystem} + */ + get vfs() { + return this.#vfs; + } + + /** + * Gets the mount prefix for the mock file system. + * @returns {string} + */ + get prefix() { + return this.#prefix; + } + + /** + * Adds a file to the mock file system. + * @param {string} filePath - The path of the file. + * @param {string|Buffer} content - The file content. + */ + addFile(filePath, content) { + // Ensure parent directories exist + const parentDir = dirname(filePath); + if (parentDir !== '/') { + this.#vfs.mkdirSync(parentDir, { __proto__: null, recursive: true }); + } + this.#vfs.writeFileSync(filePath, content); + } + + /** + * Adds a directory to the mock file system. + * @param {string} dirPath - The path of the directory. + */ + addDirectory(dirPath) { + this.#vfs.mkdirSync(dirPath, { __proto__: null, recursive: true }); + } + + /** + * Checks if a path exists in the mock file system. + * @param {string} path - The path to check (relative to prefix). + * @returns {boolean} + */ + existsSync(path) { + const fullPath = join(this.#prefix, path); + return this.#vfs.existsSync(fullPath); + } + + /** + * Restores the file system to its original state. + */ + restore() { + this.#vfs.unmount(); + } +} + +const { restore: restoreFS } = MockFSContext.prototype; + class MockTracker { #mocks = []; #timers; @@ -725,6 +802,52 @@ class MockTracker { }); } + /** + * Creates a mock file system using VFS. + * @param {object} [options] - Options for the mock file system. + * @param {string} [options.prefix] - The mount prefix for the VFS. + * @param {object} [options.files] - Initial files to add (path: content pairs). + * @returns {MockFSContext} The mock file system context. + */ + fs(options = kEmptyObject) { + validateObject(options, 'options'); + const { prefix = '/mock', files } = options; + if (files !== undefined) { + validateObject(files, 'options.files'); + } + + const VirtualFileSystem = lazyVirtualFileSystem(); + // Use overlay mode so mocked files are intercepted but real files fall through + const vfs = new VirtualFileSystem({ __proto__: null, moduleHooks: true, overlay: true }); + + // Add initial files if provided + if (files) { + const paths = ObjectKeys(files); + for (let i = 0; i < paths.length; i++) { + const filePath = paths[i]; + const content = files[filePath]; + // Ensure parent directories exist + const parentDir = dirname(filePath); + if (parentDir !== '/') { + vfs.mkdirSync(parentDir, { __proto__: null, recursive: true }); + } + vfs.writeFileSync(filePath, content); + } + } + + // Mount the VFS at the specified prefix + vfs.mount(prefix); + + const ctx = new MockFSContext(vfs, prefix); + ArrayPrototypePush(this.#mocks, { + __proto__: null, + ctx, + restore: restoreFS, + }); + + return ctx; + } + /** * Resets the mock tracker, restoring all mocks and clearing timers. */ diff --git a/lib/internal/vfs/entries.js b/lib/internal/vfs/entries.js new file mode 100644 index 00000000000000..a079adac414b4c --- /dev/null +++ b/lib/internal/vfs/entries.js @@ -0,0 +1,351 @@ +'use strict'; + +const { + Promise, + SafeMap, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +// Use path.posix.join for cross-platform consistency - VFS uses forward slashes internally +const { posix: { join } } = require('path'); +const { createFileStats, createDirectoryStats, createSymlinkStats } = require('internal/vfs/stats'); + +// Symbols for private properties +const kContent = Symbol('kContent'); +const kContentProvider = Symbol('kContentProvider'); +const kPopulate = Symbol('kPopulate'); +const kPopulated = Symbol('kPopulated'); +const kEntries = Symbol('kEntries'); +const kStats = Symbol('kStats'); +const kPath = Symbol('kPath'); +const kTarget = Symbol('kTarget'); + +/** + * Base class for virtual file system entries. + */ +class VirtualEntry { + /** + * @param {string} path The absolute path of this entry + */ + constructor(path) { + this[kPath] = path; + this[kStats] = null; + } + + /** + * Gets the absolute path of this entry. + * @returns {string} + */ + get path() { + return this[kPath]; + } + + /** + * Gets the stats for this entry. + * @returns {Stats} + */ + getStats() { + return this[kStats]; + } + + /** + * Returns true if this entry is a file. + * @returns {boolean} + */ + isFile() { + return false; + } + + /** + * Returns true if this entry is a directory. + * @returns {boolean} + */ + isDirectory() { + return false; + } + + /** + * Returns true if this entry is a symbolic link. + * @returns {boolean} + */ + isSymbolicLink() { + return false; + } +} + +/** + * Represents a virtual file with static or dynamic content. + */ +class VirtualFile extends VirtualEntry { + /** + * @param {string} path The absolute path of this file + * @param {Buffer|string|Function} content The file content or content provider + * @param {object} [options] Optional configuration + * @param {number} [options.mode] File mode (default: 0o644) + */ + constructor(path, content, options = {}) { + super(path); + + if (typeof content === 'function') { + this[kContentProvider] = content; + this[kContent] = null; + // For dynamic content, we don't know the size until we call the provider + // Use 0 as placeholder, will be updated on first access + this[kStats] = createFileStats(0, options); + } else { + this[kContentProvider] = null; + this[kContent] = typeof content === 'string' ? Buffer.from(content) : content; + this[kStats] = createFileStats(this[kContent].length, options); + } + } + + /** + * @returns {boolean} + */ + isFile() { + return true; + } + + /** + * Returns true if this file has dynamic content. + * @returns {boolean} + */ + isDynamic() { + return this[kContentProvider] !== null; + } + + /** + * Gets the file content synchronously. + * @returns {Buffer} + * @throws {Error} If content provider is async-only + */ + getContentSync() { + if (this[kContentProvider] !== null) { + const result = this[kContentProvider](); + if (result instanceof Promise) { + throw new ERR_INVALID_STATE('cannot use sync API with async content provider'); + } + const buffer = typeof result === 'string' ? Buffer.from(result) : result; + // Update stats with actual size + this[kStats] = createFileStats(buffer.length); + return buffer; + } + return this[kContent]; + } + + /** + * Gets the file content asynchronously. + * @returns {Promise} + */ + async getContent() { + if (this[kContentProvider] !== null) { + const result = await this[kContentProvider](); + const buffer = typeof result === 'string' ? Buffer.from(result) : result; + // Update stats with actual size + this[kStats] = createFileStats(buffer.length); + return buffer; + } + return this[kContent]; + } + + /** + * Gets the file size. For dynamic content, this may be 0 until first access. + * @returns {number} + */ + get size() { + return this[kStats].size; + } +} + +/** + * Represents a virtual directory with static or dynamic entries. + */ +class VirtualDirectory extends VirtualEntry { + /** + * @param {string} path The absolute path of this directory + * @param {Function} [populate] Optional callback to populate directory contents + * @param {object} [options] Optional configuration + * @param {number} [options.mode] Directory mode (default: 0o755) + */ + constructor(path, populate, options = {}) { + super(path); + this[kEntries] = new SafeMap(); + this[kPopulate] = typeof populate === 'function' ? populate : null; + this[kPopulated] = this[kPopulate] === null; // Static dirs are already populated + this[kStats] = createDirectoryStats(options); + } + + /** + * @returns {boolean} + */ + isDirectory() { + return true; + } + + /** + * Returns true if this directory has a populate callback. + * @returns {boolean} + */ + isDynamic() { + return this[kPopulate] !== null; + } + + /** + * Returns true if this directory has been populated. + * @returns {boolean} + */ + isPopulated() { + return this[kPopulated]; + } + + /** + * Ensures the directory is populated (calls populate callback if needed). + * This is synchronous - the populate callback must be synchronous. + */ + ensurePopulated() { + if (!this[kPopulated]) { + const scopedVfs = createScopedVFS(this, (name, entry) => { + this[kEntries].set(name, entry); + }); + this[kPopulate](scopedVfs); + this[kPopulated] = true; + } + } + + /** + * Gets an entry by name. + * @param {string} name The entry name + * @returns {VirtualEntry|undefined} + */ + getEntry(name) { + this.ensurePopulated(); + return this[kEntries].get(name); + } + + /** + * Checks if an entry exists. + * @param {string} name The entry name + * @returns {boolean} + */ + hasEntry(name) { + this.ensurePopulated(); + return this[kEntries].has(name); + } + + /** + * Adds an entry to this directory. + * @param {string} name The entry name + * @param {VirtualEntry} entry The entry to add + */ + addEntry(name, entry) { + this[kEntries].set(name, entry); + } + + /** + * Removes an entry from this directory. + * @param {string} name The entry name + * @returns {boolean} True if the entry was removed + */ + removeEntry(name) { + return this[kEntries].delete(name); + } + + /** + * Gets all entry names in this directory. + * @returns {string[]} + */ + getEntryNames() { + this.ensurePopulated(); + return [...this[kEntries].keys()]; + } + + /** + * Gets all entries in this directory. + * @returns {IterableIterator<[string, VirtualEntry]>} + */ + getEntries() { + this.ensurePopulated(); + return this[kEntries].entries(); + } +} + +/** + * Represents a virtual symbolic link. + */ +class VirtualSymlink extends VirtualEntry { + /** + * @param {string} path The absolute path of this symlink + * @param {string} target The symlink target (can be relative or absolute) + * @param {object} [options] Optional configuration + * @param {number} [options.mode] Symlink mode (default: 0o777) + */ + constructor(path, target, options = {}) { + super(path); + this[kTarget] = target; + this[kStats] = createSymlinkStats(target.length, options); + } + + /** + * @returns {boolean} + */ + isSymbolicLink() { + return true; + } + + /** + * Gets the symlink target path. + * @returns {string} + */ + get target() { + return this[kTarget]; + } +} + +/** + * Creates a scoped VFS interface for dynamic directory populate callbacks. + * @param {VirtualDirectory} directory The parent directory + * @param {Function} addEntry Callback to add an entry + * @returns {object} Scoped VFS interface + */ +function createScopedVFS(directory, addEntry) { + return { + __proto__: null, + addFile(name, content, options) { + const filePath = join(directory.path, name); + const file = new VirtualFile(filePath, content, options); + addEntry(name, file); + }, + addDirectory(name, populate, options) { + const dirPath = join(directory.path, name); + const dir = new VirtualDirectory(dirPath, populate, options); + addEntry(name, dir); + }, + addSymlink(name, target, options) { + const linkPath = join(directory.path, name); + const symlink = new VirtualSymlink(linkPath, target, options); + addEntry(name, symlink); + }, + }; +} + +module.exports = { + VirtualEntry, + VirtualFile, + VirtualDirectory, + VirtualSymlink, + createScopedVFS, + kContent, + kContentProvider, + kPopulate, + kPopulated, + kEntries, + kStats, + kPath, + kTarget, +}; diff --git a/lib/internal/vfs/errors.js b/lib/internal/vfs/errors.js new file mode 100644 index 00000000000000..8fd0d7cf6439d1 --- /dev/null +++ b/lib/internal/vfs/errors.js @@ -0,0 +1,175 @@ +'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, +} = 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; +} + +module.exports = { + createENOENT, + createENOTDIR, + createENOTEMPTY, + createEISDIR, + createEBADF, + createEEXIST, + createEROFS, + createEINVAL, + createELOOP, +}; diff --git a/lib/internal/vfs/fd.js b/lib/internal/vfs/fd.js new file mode 100644 index 00000000000000..3bc5811416459b --- /dev/null +++ b/lib/internal/vfs/fd.js @@ -0,0 +1,161 @@ +'use strict'; + +const { + SafeMap, + Symbol, +} = primordials; + +// Private symbols +const kFd = Symbol('kFd'); +const kEntry = Symbol('kEntry'); +const kFlags = Symbol('kFlags'); +const kPath = Symbol('kPath'); + +// FD range: 10000+ to avoid conflicts with real fds +const VFS_FD_BASE = 10_000; +let nextFd = VFS_FD_BASE; + +// 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 + * @param {string} flags The open flags (r, r+, w, w+, a, a+) + * @param {string} path The path used to open the file + */ + constructor(fd, entry, flags, path) { + this[kFd] = fd; + this[kEntry] = entry; + this[kFlags] = flags; + this[kPath] = path; + } + + /** + * Gets the file descriptor number. + * @returns {number} + */ + get fd() { + return this[kFd]; + } + + /** + * Gets the file handle. + * @returns {VirtualFileHandle} + */ + get entry() { + return this[kEntry]; + } + + /** + * Gets the current position. + * @returns {number} + */ + get position() { + return this[kEntry].position; + } + + /** + * Sets the current position. + * @param {number} pos The new position + */ + set position(pos) { + this[kEntry].position = pos; + } + + /** + * Gets the open flags. + * @returns {string} + */ + get flags() { + return this[kFlags]; + } + + /** + * Gets the path used to open the file. + * @returns {string} + */ + get path() { + return this[kPath]; + } + + /** + * Gets the content buffer synchronously. + * @returns {Buffer} + */ + getContentSync() { + return this[kEntry].readFileSync(); + } + + /** + * Gets the content buffer asynchronously. + * @returns {Promise} + */ + async getContent() { + return this[kEntry].readFile(); + } +} + +/** + * Opens a virtual file and returns its file descriptor. + * @param {VirtualFileHandle} entry The virtual file handle + * @param {string} flags The open flags + * @param {string} path The path used to open the file + * @returns {number} The file descriptor + */ +function openVirtualFd(entry, flags, path) { + const fd = nextFd++; + const vfd = new VirtualFD(fd, entry, flags, path); + 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); +} + +/** + * Checks if a file descriptor is a virtual fd. + * @param {number} fd The file descriptor number + * @returns {boolean} + */ +function isVirtualFd(fd) { + return openFDs.has(fd); +} + +/** + * Gets the count of open virtual file descriptors. + * @returns {number} + */ +function getOpenFdCount() { + return openFDs.size; +} + +module.exports = { + VirtualFD, + VFS_FD_BASE, + openVirtualFd, + getVirtualFd, + closeVirtualFd, + isVirtualFd, + getOpenFdCount, +}; diff --git a/lib/internal/vfs/file_handle.js b/lib/internal/vfs/file_handle.js new file mode 100644 index 00000000000000..6a8bac5a6bd726 --- /dev/null +++ b/lib/internal/vfs/file_handle.js @@ -0,0 +1,533 @@ +'use strict'; + +const { + MathMin, + Symbol, +} = 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. + * @private + */ + _checkClosed() { + if (this[kClosed]) { + throw createEBADF('read'); + } + } + + /** + * 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + throw new ERR_METHOD_NOT_IMPLEMENTED('writeFileSync'); + } + + /** + * Gets file stats. + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(options) { + this._checkClosed(); + 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(); + throw new ERR_METHOD_NOT_IMPLEMENTED('statSync'); + } + + /** + * Truncates the file. + * @param {number} [len] The new length + * @returns {Promise} + */ + async truncate(len) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('truncate'); + } + + /** + * Truncates the file synchronously. + * @param {number} [len] The new length + */ + truncateSync(len) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('truncateSync'); + } + + /** + * Closes the file handle. + * @returns {Promise} + */ + async close() { + this[kClosed] = true; + } + + /** + * Closes the file handle synchronously. + */ + closeSync() { + this[kClosed] = true; + } +} + +/** + * A file handle for in-memory file content. + * Used by MemoryProvider and similar providers. + */ +class MemoryFileHandle extends VirtualFileHandle { + #content; + #entry; + #getStats; + + /** + * @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.#entry = entry; + this.#getStats = getStats; + + // Handle different open modes + if (flags === 'w' || flags === 'w+') { + // Write mode: truncate + this.#content = Buffer.alloc(0); + if (entry) { + entry.content = this.#content; + } + } else if (flags === 'a' || flags === 'a+') { + // Append mode: position at end + this.position = this.#content.length; + } + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * Gets the raw stored content (without dynamic resolution). + * @returns {Buffer} + */ + get _rawContent() { + 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(); + + // Get content (resolves dynamic content providers) + const content = this.content; + const readPos = position !== null && position !== undefined ? 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(); + + const writePos = position !== null && position !== undefined ? position : this.position; + const data = buffer.subarray(offset, offset + length); + + // Expand content if needed + if (writePos + length > this.#content.length) { + const newContent = Buffer.alloc(writePos + length); + this.#content.copy(newContent, 0, 0, this.#content.length); + this.#content = newContent; + } + + // Write the data + data.copy(this.#content, writePos); + + // Update the entry's content + if (this.#entry) { + this.#entry.content = this.#content; + } + + // 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(); + + // 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(); + + // 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 (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(data, options) { + this._checkClosed(); + + const buffer = typeof data === 'string' ? Buffer.from(data, options?.encoding) : data; + this.#content = Buffer.from(buffer); + + // Update the entry's content + if (this.#entry) { + this.#entry.content = this.#content; + } + + this.position = this.#content.length; + } + + /** + * 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(); + if (this.#getStats) { + return this.#getStats(this.#content.length); + } + 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(); + + if (len < this.#content.length) { + this.#content = this.#content.subarray(0, len); + } else if (len > this.#content.length) { + const newContent = Buffer.alloc(len); + this.#content.copy(newContent, 0, 0, this.#content.length); + this.#content = newContent; + } + + // Update the entry's content + if (this.#entry) { + this.#entry.content = this.#content; + } + } + + /** + * 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..373086257873ee --- /dev/null +++ b/lib/internal/vfs/file_system.js @@ -0,0 +1,971 @@ +'use strict'; + +const { + ObjectFreeze, + Symbol, +} = primordials; + +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { validateBoolean } = require('internal/validators'); +const { MemoryProvider } = require('internal/vfs/providers/memory'); +const { + normalizePath, + isUnderMountPoint, + getRelativePath, + joinMountPath, + isAbsolutePath, +} = require('internal/vfs/router'); +const { + openVirtualFd, + getVirtualFd, + closeVirtualFd, +} = require('internal/vfs/fd'); +const { + createENOENT, + createENOTDIR, + createEBADF, +} = require('internal/vfs/errors'); +const { createVirtualReadStream } = require('internal/vfs/streams'); +const { emitExperimentalWarning } = require('internal/util'); + +// Private symbols +const kProvider = Symbol('kProvider'); +const kMountPoint = Symbol('kMountPoint'); +const kMounted = Symbol('kMounted'); +const kOverlay = Symbol('kOverlay'); +const kModuleHooks = Symbol('kModuleHooks'); +const kPromises = Symbol('kPromises'); +const kVirtualCwd = Symbol('kVirtualCwd'); +const kVirtualCwdEnabled = Symbol('kVirtualCwdEnabled'); +const kOriginalChdir = Symbol('kOriginalChdir'); +const kOriginalCwd = Symbol('kOriginalCwd'); + +// Lazy-loaded module hooks +let registerVFS; +let unregisterVFS; + +function loadModuleHooks() { + if (!registerVFS) { + const hooks = require('internal/vfs/module_hooks'); + registerVFS = hooks.registerVFS; + unregisterVFS = hooks.unregisterVFS; + } +} + +/** + * Virtual File System implementation using Provider architecture. + * Wraps a Provider and provides mount point routing and virtual cwd. + */ +class VirtualFileSystem { + /** + * @param {VirtualProvider|object} [providerOrOptions] The provider to use, or options + * @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 + * @param {boolean} [options.overlay] Whether to enable overlay mode (only intercept existing files) + */ + constructor(providerOrOptions, options = {}) { + emitExperimentalWarning('VirtualFileSystem'); + + // 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; + } + } + + // Validate boolean options + if (options.moduleHooks !== undefined) { + validateBoolean(options.moduleHooks, 'options.moduleHooks'); + } + if (options.virtualCwd !== undefined) { + validateBoolean(options.virtualCwd, 'options.virtualCwd'); + } + if (options.overlay !== undefined) { + validateBoolean(options.overlay, 'options.overlay'); + } + + this[kProvider] = provider ?? new MemoryProvider(); + this[kMountPoint] = null; + this[kMounted] = false; + this[kOverlay] = options.overlay === true; + this[kModuleHooks] = options.moduleHooks !== false; + this[kPromises] = null; // Lazy-initialized + this[kVirtualCwdEnabled] = options.virtualCwd === true; + this[kVirtualCwd] = null; // Set when chdir() is called + this[kOriginalChdir] = null; // Saved process.chdir + this[kOriginalCwd] = null; // Saved process.cwd + } + + /** + * Gets the underlying provider. + * @returns {VirtualProvider} + */ + get provider() { + return this[kProvider]; + } + + /** + * Gets the mount point path, or null if not mounted. + * @returns {string|null} + */ + get mountPoint() { + return this[kMountPoint]; + } + + /** + * Returns true if VFS is mounted. + * @returns {boolean} + */ + get mounted() { + return this[kMounted]; + } + + /** + * Returns true if the provider is read-only. + * @returns {boolean} + */ + get readonly() { + return this[kProvider].readonly; + } + + /** + * 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. + * @returns {boolean} + */ + get overlay() { + return this[kOverlay]; + } + + /** + * Returns true if virtual working directory is enabled. + * @returns {boolean} + */ + get virtualCwdEnabled() { + return this[kVirtualCwdEnabled]; + } + + // ==================== Virtual Working Directory ==================== + + /** + * Gets the virtual current working directory. + * Returns null if no virtual cwd is set. + * @returns {string|null} + */ + cwd() { + if (!this[kVirtualCwdEnabled]) { + throw new ERR_INVALID_STATE('virtual cwd is not enabled'); + } + return this[kVirtualCwd]; + } + + /** + * Sets the virtual current working directory. + * The path must exist in the VFS. + * @param {string} dirPath The directory path to set as cwd + */ + chdir(dirPath) { + if (!this[kVirtualCwdEnabled]) { + throw new ERR_INVALID_STATE('virtual cwd is not enabled'); + } + + const providerPath = this._toProviderPath(dirPath); + const stats = this[kProvider].statSync(providerPath); + + if (!stats.isDirectory()) { + throw createENOTDIR('chdir', dirPath); + } + + // Store the full path (with mount point) as virtual cwd + this[kVirtualCwd] = this._toMountedPath(providerPath); + } + + /** + * Resolves a path relative to the virtual cwd if set. + * If the path is absolute or no virtual cwd is set, returns the path as-is. + * @param {string} inputPath The path to resolve + * @returns {string} The resolved path + */ + resolvePath(inputPath) { + // If path is absolute, return as-is + if (isAbsolutePath(inputPath)) { + return normalizePath(inputPath); + } + + // If virtual cwd is enabled and set, resolve relative to it + if (this[kVirtualCwdEnabled] && this[kVirtualCwd] !== null) { + const resolved = `${this[kVirtualCwd]}/${inputPath}`; + return normalizePath(resolved); + } + + // Fall back to normalizing the path (will use real cwd) + return normalizePath(inputPath); + } + + // ==================== Mount ==================== + + /** + * Mounts the VFS at a specific path prefix. + * @param {string} prefix The mount point path + */ + mount(prefix) { + if (this[kMounted]) { + throw new ERR_INVALID_STATE('VFS is already mounted'); + } + this[kMountPoint] = normalizePath(prefix); + this[kMounted] = true; + if (this[kModuleHooks]) { + loadModuleHooks(); + registerVFS(this); + } + if (this[kVirtualCwdEnabled]) { + this._hookProcessCwd(); + } + } + + /** + * Unmounts the VFS. + */ + unmount() { + this._unhookProcessCwd(); + if (this[kModuleHooks]) { + loadModuleHooks(); + unregisterVFS(this); + } + this[kMountPoint] = null; + this[kMounted] = false; + this[kVirtualCwd] = null; // Reset virtual cwd on unmount + } + + /** + * Hooks process.chdir and process.cwd to support virtual cwd. + * @private + */ + _hookProcessCwd() { + if (this[kOriginalChdir] !== null) { + return; + } + + const vfs = this; + + this[kOriginalChdir] = process.chdir; + this[kOriginalCwd] = process.cwd; + + process.chdir = function chdir(directory) { + const normalized = normalizePath(directory); + + if (vfs.shouldHandle(normalized)) { + vfs.chdir(normalized); + return; + } + + return vfs[kOriginalChdir].call(process, directory); + }; + + process.cwd = function cwd() { + if (vfs[kVirtualCwd] !== null) { + return vfs[kVirtualCwd]; + } + + return vfs[kOriginalCwd].call(process); + }; + } + + /** + * Restores original process.chdir and process.cwd. + * @private + */ + _unhookProcessCwd() { + if (this[kOriginalChdir] === null) { + return; + } + + process.chdir = this[kOriginalChdir]; + process.cwd = this[kOriginalCwd]; + + this[kOriginalChdir] = null; + this[kOriginalCwd] = null; + } + + // ==================== Path Resolution ==================== + + /** + * Converts a mounted path to a provider-relative path. + * @param {string} inputPath The path to convert + * @returns {string} The provider-relative path + * @private + */ + _toProviderPath(inputPath) { + const resolved = this.resolvePath(inputPath); + + if (this[kMounted] && this[kMountPoint]) { + if (!isUnderMountPoint(resolved, this[kMountPoint])) { + throw createENOENT('open', inputPath); + } + return getRelativePath(resolved, this[kMountPoint]); + } + + return resolved; + } + + /** + * Converts a provider-relative path to a mounted path. + * @param {string} providerPath The provider-relative path + * @returns {string} The mounted path + * @private + */ + _toMountedPath(providerPath) { + if (this[kMounted] && this[kMountPoint]) { + return joinMountPath(this[kMountPoint], providerPath); + } + return providerPath; + } + + /** + * Checks if a path should be handled by this VFS. + * In mount mode (default), returns true for all paths under the mount point. + * In overlay mode, returns true only if the path exists in the VFS. + * @param {string} inputPath The path to check + * @returns {boolean} + */ + shouldHandle(inputPath) { + if (!this[kMounted] || !this[kMountPoint]) { + return false; + } + + const normalized = normalizePath(inputPath); + if (!isUnderMountPoint(normalized, this[kMountPoint])) { + return false; + } + + // In overlay mode, only handle if the path exists in VFS + if (this[kOverlay]) { + try { + const providerPath = getRelativePath(normalized, this[kMountPoint]); + return this[kProvider].existsSync(providerPath); + } catch { + return false; + } + } + + return true; + } + + // ==================== 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); + return this[kProvider].readdirSync(providerPath, options); + } + + /** + * 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); + const result = this[kProvider].mkdirSync(providerPath, options); + if (result !== undefined) { + return this._toMountedPath(result); + } + return undefined; + } + + /** + * 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); + const realProviderPath = this[kProvider].realpathSync(providerPath, options); + return this._toMountedPath(realProviderPath); + } + + /** + * 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); + } + + /** + * Returns the stat result code for module resolution. + * @param {string} filePath The path to check + * @returns {number} 0 for file, 1 for directory, -2 for not found + */ + internalModuleStat(filePath) { + try { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].internalModuleStat(providerPath); + } catch { + return -2; + } + } + + // ==================== 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, flags, this._toMountedPath(providerPath)); + } + + /** + * 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); + } + + /** + * 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)) + .catch((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)) + .catch((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)) + .catch((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)) + .catch((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)) + .catch((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, this._toMountedPath(realPath))) + .catch((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)) + .catch((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)) + .catch((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, flags, this._toMountedPath(providerPath)); + callback(null, fd); + }) + .catch((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); + }) + .catch((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)) + .catch((err) => 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)) + .catch((err) => 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 createVirtualReadStream(this, filePath, options); + } + + // ==================== Promise API ==================== + + /** + * Gets the promises API for this VFS instance. + * @returns {object} Promise-based fs methods + */ + get promises() { + if (this[kPromises] === null) { + this[kPromises] = createPromisesAPI(this); + } + return this[kPromises]; + } +} + +/** + * Creates the promises API object for a VFS instance. + * @param {VirtualFileSystem} vfs The VFS instance + * @returns {object} Promise-based fs methods + */ +function createPromisesAPI(vfs) { + const provider = vfs[kProvider]; + + return ObjectFreeze({ + async readFile(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.readFile(providerPath, options); + }, + + async writeFile(filePath, data, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.writeFile(providerPath, data, options); + }, + + async appendFile(filePath, data, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.appendFile(providerPath, data, options); + }, + + async stat(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.stat(providerPath, options); + }, + + async lstat(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.lstat(providerPath, options); + }, + + async readdir(dirPath, options) { + const providerPath = vfs._toProviderPath(dirPath); + return provider.readdir(providerPath, options); + }, + + async mkdir(dirPath, options) { + const providerPath = vfs._toProviderPath(dirPath); + const result = await provider.mkdir(providerPath, options); + if (result !== undefined) { + return vfs._toMountedPath(result); + } + return undefined; + }, + + async rmdir(dirPath) { + const providerPath = vfs._toProviderPath(dirPath); + return provider.rmdir(providerPath); + }, + + async unlink(filePath) { + const providerPath = vfs._toProviderPath(filePath); + return provider.unlink(providerPath); + }, + + async rename(oldPath, newPath) { + const oldProviderPath = vfs._toProviderPath(oldPath); + const newProviderPath = vfs._toProviderPath(newPath); + return provider.rename(oldProviderPath, newProviderPath); + }, + + async copyFile(src, dest, mode) { + const srcProviderPath = vfs._toProviderPath(src); + const destProviderPath = vfs._toProviderPath(dest); + return provider.copyFile(srcProviderPath, destProviderPath, mode); + }, + + async realpath(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + const realPath = await provider.realpath(providerPath, options); + return vfs._toMountedPath(realPath); + }, + + async readlink(linkPath, options) { + const providerPath = vfs._toProviderPath(linkPath); + return provider.readlink(providerPath, options); + }, + + async symlink(target, path, type) { + const providerPath = vfs._toProviderPath(path); + return provider.symlink(target, providerPath, type); + }, + + async access(filePath, mode) { + const providerPath = vfs._toProviderPath(filePath); + return provider.access(providerPath, mode); + }, + }); +} + +module.exports = { + VirtualFileSystem, +}; diff --git a/lib/internal/vfs/module_hooks.js b/lib/internal/vfs/module_hooks.js new file mode 100644 index 00000000000000..20e3fd6d75f319 --- /dev/null +++ b/lib/internal/vfs/module_hooks.js @@ -0,0 +1,600 @@ +'use strict'; + +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + ArrayPrototypeSplice, + StringPrototypeEndsWith, + StringPrototypeStartsWith, +} = primordials; + +const { dirname, isAbsolute, resolve } = require('path'); +const { normalizePath } = require('internal/vfs/router'); +const { pathToFileURL, fileURLToPath, URL } = require('internal/url'); +const { createENOENT } = require('internal/vfs/errors'); + +// Registry of active VFS instances +const activeVFSList = []; + +// Original Module._stat function (set once when first VFS activates) +let originalStat = null; +// Original fs.readFileSync function (set once when first VFS activates) +let originalReadFileSync = null; +// Original fs.realpathSync function +let originalRealpathSync = null; +// Original fs.lstatSync function +let originalLstatSync = null; +// Original fs.statSync function +let originalStatSync = null; +// Original fs.readdirSync function +let originalReaddirSync = null; +// Original fs.existsSync function +let originalExistsSync = null; +// Original fs/promises.readdir function +let originalPromisesReaddir = null; +// Original fs/promises.lstat function +let originalPromisesLstat = null; +// Track if hooks are installed +let hooksInstalled = false; + +/** + * Registers a VFS instance to be checked for CJS module loading. + * @param {VirtualFileSystem} vfs The VFS instance to register + */ +function registerVFS(vfs) { + if (ArrayPrototypeIndexOf(activeVFSList, vfs) === -1) { + ArrayPrototypePush(activeVFSList, vfs); + if (!hooksInstalled) { + installHooks(); + } + } +} + +/** + * Unregisters a VFS instance. + * @param {VirtualFileSystem} vfs The VFS instance to unregister + */ +function unregisterVFS(vfs) { + const index = ArrayPrototypeIndexOf(activeVFSList, vfs); + if (index !== -1) { + ArrayPrototypeSplice(activeVFSList, index, 1); + } + // Note: We don't uninstall hooks even when list is empty, + // as another VFS might be registered later. +} + +/** + * Checks all active VFS instances for a file/directory. + * @param {string} filename The absolute path to check + * @returns {{ vfs: VirtualFileSystem, result: number }|null} + */ +function findVFSForStat(filename) { + const normalized = normalizePath(filename); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + const result = vfs.internalModuleStat(normalized); + // For mounted VFS, always return result (even -2 for ENOENT within mount) + // For overlay VFS, only return if found + if (vfs.mounted || result >= 0) { + return { vfs, result }; + } + } + } + return null; +} + +/** + * Checks all active VFS instances for file content. + * @param {string} filename The absolute path to read + * @param {string|object} options Read options + * @returns {{ vfs: VirtualFileSystem, content: Buffer|string }|null} + */ +function findVFSForRead(filename, options) { + const normalized = normalizePath(filename); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + // Check if the file actually exists in VFS + if (vfs.existsSync(normalized)) { + // Only read files, not directories + const statResult = vfs.internalModuleStat(normalized); + if (statResult !== 0) { + // Not a file (1 = dir, -2 = not found) + // Let the real fs handle it (will throw appropriate error) + return null; + } + try { + const content = vfs.readFileSync(normalized, options); + return { vfs, content }; + } catch (e) { + // If read fails, fall through to default fs + // unless we're in mounted mode (where we should return the error) + if (vfs.mounted) { + throw e; + } + } + } else if (vfs.mounted) { + // In mounted mode, if path is under mount point but doesn't exist, + // don't fall through to real fs - throw ENOENT + throw createENOENT('open', filename); + } + } + } + return null; +} + +/** + * Checks all active VFS instances for existence. + * @param {string} filename The absolute path to check + * @returns {{ vfs: VirtualFileSystem, exists: boolean }|null} + */ +function findVFSForExists(filename) { + const normalized = normalizePath(filename); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + // For mounted VFS, we handle the path (return exists result) + // For overlay VFS, we only handle if it exists in VFS + const exists = vfs.existsSync(normalized); + if (vfs.mounted || exists) { + return { vfs, exists }; + } + } + } + return null; +} + +/** + * Checks all active VFS instances for realpath. + * @param {string} filename The path to resolve + * @returns {{ vfs: VirtualFileSystem, realpath: string }|null} + */ +function findVFSForRealpath(filename) { + const normalized = normalizePath(filename); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + if (vfs.existsSync(normalized)) { + try { + const realpath = vfs.realpathSync(normalized); + return { vfs, realpath }; + } catch (e) { + if (vfs.mounted) { + throw e; + } + } + } else if (vfs.mounted) { + throw createENOENT('realpath', filename); + } + } + } + return null; +} + +/** + * Checks all active VFS instances for stat/lstat. + * @param {string} filename The path to stat + * @returns {{ vfs: VirtualFileSystem, stats: Stats }|null} + */ +function findVFSForFsStat(filename) { + const normalized = normalizePath(filename); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + if (vfs.existsSync(normalized)) { + try { + const stats = vfs.statSync(normalized); + return { vfs, stats }; + } catch (e) { + if (vfs.mounted) { + throw e; + } + } + } else if (vfs.mounted) { + throw createENOENT('stat', filename); + } + } + } + return null; +} + +/** + * Checks all active VFS instances for readdir. + * @param {string} dirname The directory path + * @param {object} options The readdir options + * @returns {{ vfs: VirtualFileSystem, entries: string[]|Dirent[] }|null} + */ +function findVFSForReaddir(dirname, options) { + const normalized = normalizePath(dirname); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + if (vfs.existsSync(normalized)) { + try { + const entries = vfs.readdirSync(normalized, options); + return { vfs, entries }; + } catch (e) { + if (vfs.mounted) { + throw e; + } + } + } else if (vfs.mounted) { + throw createENOENT('scandir', dirname); + } + } + } + return null; +} + +/** + * Async version: Checks all active VFS instances for readdir. + * @param {string} dirname The directory path + * @param {object} options The readdir options + * @returns {Promise<{ vfs: VirtualFileSystem, entries: string[]|Dirent[] }|null>} + */ +async function findVFSForReaddirAsync(dirname, options) { + const normalized = normalizePath(dirname); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + if (vfs.existsSync(normalized)) { + try { + const entries = await vfs.promises.readdir(normalized, options); + return { __proto__: null, vfs, entries }; + } catch (e) { + if (vfs.mounted) { + throw e; + } + } + } else if (vfs.mounted) { + throw createENOENT('scandir', dirname); + } + } + } + return null; +} + +/** + * Async version: Checks all active VFS instances for lstat. + * @param {string} filename The path to stat + * @returns {Promise<{ vfs: VirtualFileSystem, stats: Stats }|null>} + */ +async function findVFSForLstatAsync(filename) { + const normalized = normalizePath(filename); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized)) { + if (vfs.existsSync(normalized)) { + try { + const stats = await vfs.promises.lstat(normalized); + return { __proto__: null, vfs, stats }; + } catch (e) { + if (vfs.mounted) { + throw e; + } + } + } else if (vfs.mounted) { + throw createENOENT('lstat', filename); + } + } + } + return null; +} + +/** + * Determine module format from file extension. + * @param {string} url The file URL + * @returns {string} The format ('module', 'commonjs', or 'json') + */ +function getFormatFromExtension(url) { + if (StringPrototypeEndsWith(url, '.mjs')) { + return 'module'; + } + if (StringPrototypeEndsWith(url, '.cjs')) { + return 'commonjs'; + } + if (StringPrototypeEndsWith(url, '.json')) { + return 'json'; + } + // Default to commonjs for .js files + // TODO: Check package.json "type" field for proper detection + return 'commonjs'; +} + +/** + * Convert a file path or file URL to a normalized file path. + * @param {string} urlOrPath URL or path string + * @returns {string} Normalized file path + */ +function urlToPath(urlOrPath) { + if (StringPrototypeStartsWith(urlOrPath, 'file:')) { + return fileURLToPath(urlOrPath); + } + return urlOrPath; +} + +/** + * ESM resolve hook for VFS. + * @param {string} specifier The module specifier + * @param {object} context The resolve context + * @param {Function} nextResolve The next resolve function in the chain + * @returns {object} The resolve result + */ +function vfsResolveHook(specifier, context, nextResolve) { + // Skip node: built-ins + if (StringPrototypeStartsWith(specifier, 'node:')) { + return nextResolve(specifier, context); + } + + // Convert specifier to a path we can check + let checkPath; + if (StringPrototypeStartsWith(specifier, 'file:')) { + checkPath = fileURLToPath(specifier); + } else if (isAbsolute(specifier)) { + // Absolute path (Unix / or Windows C:\) + checkPath = specifier; + } else if (specifier[0] === '.') { + // Relative path - need to resolve against parent + if (context.parentURL) { + const parentPath = urlToPath(context.parentURL); + const parentDir = dirname(parentPath); + checkPath = resolve(parentDir, specifier); + } else { + return nextResolve(specifier, context); + } + } else { + // Bare specifier (like 'lodash') - let default resolver handle it + return nextResolve(specifier, context); + } + + // Check if any VFS handles this path + const normalized = normalizePath(checkPath); + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized) && vfs.existsSync(normalized)) { + // Only resolve files, let directories go through normal resolution + // (which handles package.json, index.js, etc.) + const statResult = vfs.internalModuleStat(normalized); + if (statResult !== 0) { + // Not a file (1 = dir), let default resolver handle it + return nextResolve(specifier, context); + } + const url = pathToFileURL(normalized).href; + const format = getFormatFromExtension(normalized); + return { + url, + format, + shortCircuit: true, + }; + } + } + + // Not in VFS, let the default resolver handle it + return nextResolve(specifier, context); +} + +/** + * ESM load hook for VFS. + * @param {string} url The module URL + * @param {object} context The load context + * @param {Function} nextLoad The next load function in the chain + * @returns {object} The load result + */ +function vfsLoadHook(url, context, nextLoad) { + // Skip node: built-ins + if (StringPrototypeStartsWith(url, 'node:')) { + return nextLoad(url, context); + } + + // Only handle file: URLs + if (!StringPrototypeStartsWith(url, 'file:')) { + return nextLoad(url, context); + } + + const filePath = fileURLToPath(url); + const normalized = normalizePath(filePath); + + // Check if any VFS handles this path + for (let i = 0; i < activeVFSList.length; i++) { + const vfs = activeVFSList[i]; + if (vfs.shouldHandle(normalized) && vfs.existsSync(normalized)) { + // Only load files, not directories + const statResult = vfs.internalModuleStat(normalized); + if (statResult !== 0) { + // Not a file (0 = file, 1 = dir, -2 = not found) + // Let the default loader handle it + return nextLoad(url, context); + } + try { + const content = vfs.readFileSync(normalized, 'utf8'); + const format = context.format || getFormatFromExtension(normalized); + return { + format, + source: content, + shortCircuit: true, + }; + } catch (e) { + // If read fails, fall through to default loader + if (vfs.mounted) { + throw e; + } + } + } + } + + // Not in VFS, let the default loader handle it + return nextLoad(url, context); +} + +/** + * Install hooks into Module._stat and various fs functions. + * Note: fs and internal modules are required here (not at top level) to avoid + * circular dependencies during bootstrap. This module may be loaded early. + */ +function installHooks() { + if (hooksInstalled) { + return; + } + + const Module = require('internal/modules/cjs/loader').Module; + const fs = require('fs'); + + // Save originals + originalStat = Module._stat; + originalReadFileSync = fs.readFileSync; + originalRealpathSync = fs.realpathSync; + originalLstatSync = fs.lstatSync; + originalStatSync = fs.statSync; + + // Override Module._stat + // This uses the setter which emits an experimental warning, but that's acceptable + // for now since VFS integration IS experimental. + Module._stat = function _stat(filename) { + const vfsResult = findVFSForStat(filename); + if (vfsResult !== null) { + return vfsResult.result; + } + return originalStat(filename); + }; + + // Override fs.readFileSync + // We need to be careful to only intercept when VFS should handle the path + fs.readFileSync = function readFileSync(path, options) { + // Only intercept string paths (not file descriptors) + if (typeof path === 'string' || path instanceof URL) { + const pathStr = typeof path === 'string' ? path : path.pathname; + const vfsResult = findVFSForRead(pathStr, options); + if (vfsResult !== null) { + return vfsResult.content; + } + } + return originalReadFileSync.call(fs, path, options); + }; + + // Override fs.realpathSync + fs.realpathSync = function realpathSync(path, options) { + if (typeof path === 'string' || path instanceof URL) { + const pathStr = typeof path === 'string' ? path : path.pathname; + const vfsResult = findVFSForRealpath(pathStr); + if (vfsResult !== null) { + return vfsResult.realpath; + } + } + return originalRealpathSync.call(fs, path, options); + }; + // Preserve the .native method + fs.realpathSync.native = originalRealpathSync.native; + + // Override fs.lstatSync + fs.lstatSync = function lstatSync(path, options) { + if (typeof path === 'string' || path instanceof URL) { + const pathStr = typeof path === 'string' ? path : path.pathname; + const vfsResult = findVFSForFsStat(pathStr); + if (vfsResult !== null) { + return vfsResult.stats; + } + } + return originalLstatSync.call(fs, path, options); + }; + + // Override fs.statSync + fs.statSync = function statSync(path, options) { + if (typeof path === 'string' || path instanceof URL) { + const pathStr = typeof path === 'string' ? path : path.pathname; + const vfsResult = findVFSForFsStat(pathStr); + if (vfsResult !== null) { + return vfsResult.stats; + } + } + return originalStatSync.call(fs, path, options); + }; + + // Override fs.readdirSync (needed for glob support) + originalReaddirSync = fs.readdirSync; + fs.readdirSync = function readdirSync(path, options) { + if (typeof path === 'string' || path instanceof URL) { + const pathStr = typeof path === 'string' ? path : path.pathname; + const vfsResult = findVFSForReaddir(pathStr, options); + if (vfsResult !== null) { + return vfsResult.entries; + } + } + return originalReaddirSync.call(fs, path, options); + }; + + // Override fs.existsSync + originalExistsSync = fs.existsSync; + fs.existsSync = function existsSync(path) { + if (typeof path === 'string' || path instanceof URL) { + const pathStr = typeof path === 'string' ? path : path.pathname; + const vfsResult = findVFSForExists(pathStr); + if (vfsResult !== null) { + return vfsResult.exists; + } + } + return originalExistsSync.call(fs, path); + }; + + // Hook fs/promises for async glob support + const fsPromises = require('fs/promises'); + + // Override fs/promises.readdir (needed for async glob support) + originalPromisesReaddir = fsPromises.readdir; + fsPromises.readdir = async function readdir(path, options) { + if (typeof path === 'string' || path instanceof URL) { + const pathStr = typeof path === 'string' ? path : path.pathname; + const vfsResult = await findVFSForReaddirAsync(pathStr, options); + if (vfsResult !== null) { + return vfsResult.entries; + } + } + return originalPromisesReaddir.call(fsPromises, path, options); + }; + + // Override fs/promises.lstat (needed for async glob support) + originalPromisesLstat = fsPromises.lstat; + fsPromises.lstat = async function lstat(path, options) { + if (typeof path === 'string' || path instanceof URL) { + const pathStr = typeof path === 'string' ? path : path.pathname; + const vfsResult = await findVFSForLstatAsync(pathStr); + if (vfsResult !== null) { + return vfsResult.stats; + } + } + return originalPromisesLstat.call(fsPromises, path, options); + }; + + // Register ESM hooks using Module.registerHooks + Module.registerHooks({ + resolve: vfsResolveHook, + load: vfsLoadHook, + }); + + hooksInstalled = true; +} + +/** + * Get the count of active VFS instances. + * @returns {number} + */ +function getActiveVFSCount() { + return activeVFSList.length; +} + +/** + * Check if hooks are installed. + * @returns {boolean} + */ +function areHooksInstalled() { + return hooksInstalled; +} + +module.exports = { + registerVFS, + unregisterVFS, + findVFSForStat, + findVFSForRead, + getActiveVFSCount, + areHooksInstalled, +}; diff --git a/lib/internal/vfs/provider.js b/lib/internal/vfs/provider.js new file mode 100644 index 00000000000000..5b5ac432d7d874 --- /dev/null +++ b/lib/internal/vfs/provider.js @@ -0,0 +1,499 @@ +'use strict'; + +const { + ERR_METHOD_NOT_IMPLEMENTED, +} = require('internal/errors').codes; + +const { + createEROFS, +} = require('internal/vfs/errors'); + +/** + * 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; + } + + // === 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 handle = await this.open(path, 'r'); + 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 handle = this.openSync(path, 'r'); + 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 handle = await this.open(path, 'w', 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 handle = this.openSync(path, 'w', 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 handle = await this.open(path, 'a', 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 handle = this.openSync(path, 'a', 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); + } + 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); + } + const content = this.readFileSync(src); + this.writeFileSync(dest, content); + } + + /** + * Returns the stat result code for module resolution. + * Used by Module._stat override. + * @param {string} path The path to check + * @returns {number} 0 for file, 1 for directory, -2 for not found + */ + internalModuleStat(path) { + try { + const stats = this.statSync(path); + if (stats.isDirectory()) { + return 1; + } + return 0; + } catch { + return -2; // ENOENT + } + } + + /** + * 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) { + // Default: just check if the path exists + await this.stat(path); + } + + /** + * Checks file accessibility synchronously. + * @param {string} path The path to check + * @param {number} [mode] Access mode + */ + accessSync(path, mode) { + // Default: just check if the path exists + this.statSync(path); + } + + // === 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'); + } +} + +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..b12944b4bedb24 --- /dev/null +++ b/lib/internal/vfs/providers/memory.js @@ -0,0 +1,701 @@ +'use strict'; + +const { + ArrayPrototypePush, + DateNow, + SafeMap, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { MemoryFileHandle } = require('internal/vfs/file_handle'); +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { + createENOENT, + createENOTDIR, + createENOTEMPTY, + createEISDIR, + createEEXIST, + createEINVAL, + createELOOP, +} = require('internal/vfs/errors'); +const { + createFileStats, + createDirectoryStats, + createSymlinkStats, +} = require('internal/vfs/stats'); +const { Dirent } = require('internal/fs/utils'); +const { + fs: { + UV_DIRENT_FILE, + UV_DIRENT_DIR, + UV_DIRENT_LINK, + }, +} = internalBinding('constants'); + +// Private symbols +const kRoot = Symbol('kRoot'); +const kReadonly = Symbol('kReadonly'); + +// 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 = {}) { + 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? + const now = DateNow(); + 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 (result && typeof result.then === 'function') { + // 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; + } + + get readonly() { + return this[kReadonly]; + } + + /** + * 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) { + // Normalize slashes + let normalized = path.replace(/\\/g, '/'); + if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + + // Split into segments and resolve . and .. + const segments = normalized.split('/').filter((s) => s !== '' && s !== '.'); + const resolved = []; + for (const segment of segments) { + if (segment === '..') { + // Go up one level (but don't go above root) + if (resolved.length > 0) { + resolved.pop(); + } + } else { + resolved.push(segment); + } + } + + return '/' + resolved.join('/'); + } + + /** + * Splits a path into segments. + * @param {string} path Normalized path + * @returns {string[]} Path segments + */ + _splitPath(path) { + if (path === '/') { + return []; + } + return path.slice(1).split('/'); + } + + /** + * Gets the parent path. + * @param {string} path Normalized path + * @returns {string|null} Parent path or null for root + */ + _getParentPath(path) { + if (path === '/') { + return null; + } + const lastSlash = path.lastIndexOf('/'); + if (lastSlash === 0) { + return '/'; + } + return path.slice(0, lastSlash); + } + + /** + * Gets the base name. + * @param {string} path Normalized path + * @returns {string} Base name + */ + _getBaseName(path) { + const lastSlash = path.lastIndexOf('/'); + return path.slice(lastSlash + 1); + } + + /** + * 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 = this._getParentPath(symlinkPath); + if (parentPath === null) { + return this._normalizePath('/' + target); + } + return this._normalizePath(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]; + + // Follow symlinks for intermediate path components + if (current.isSymbolicLink() && followSymlinks) { + 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 = 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) { + const parentPath = this._getParentPath(path); + if (parentPath === null) { + return this[kRoot]; + } + + 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 = '/' + segments.slice(0, i).join('/'); + 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 = '/' + segments.slice(0, i).join('/'); + 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; + } + + if (!current.isDirectory()) { + throw createENOTDIR(syscall, path); + } + + // Ensure final directory is populated + const finalPath = '/' + segments.join('/'); + this._ensurePopulated(current, finalPath); + + return current; + } + + /** + * Creates stats for an entry. + * @param {MemoryEntry} entry The entry + * @param {number} [size] Override size for files + * @returns {Stats} + */ + _createStats(entry, size) { + const options = { + mode: entry.mode, + mtimeMs: entry.mtime, + ctimeMs: entry.ctime, + birthtimeMs: entry.birthtime, + }; + + if (entry.isFile()) { + return createFileStats(size !== undefined ? size : entry.content.length, 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); + + // Handle create modes + const isCreate = flags === 'w' || flags === 'w+' || flags === 'a' || flags === 'a+'; + + let entry; + try { + entry = this._getEntry(normalized, 'open'); + } catch (err) { + if (err.code === 'ENOENT' && isCreate) { + // Create the file + const parent = this._ensureParent(normalized, true, 'open'); + const name = this._getBaseName(normalized); + entry = new MemoryEntry(TYPE_FILE, { mode }); + entry.content = Buffer.alloc(0); + parent.children.set(name, entry); + } else { + throw err; + } + } + + 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); + } + + async stat(path, options) { + return this.statSync(path, options); + } + + lstatSync(path, options) { + const entry = this._getEntry(path, 'lstat', false); + return this._createStats(entry); + } + + 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 names = [...entry.children.keys()]; + + if (options?.withFileTypes) { + const normalized = this._normalizePath(path); + const dirents = []; + for (const name of names) { + const childEntry = entry.children.get(name); + 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 names; + } + + async readdir(path, options) { + return this.readdirSync(path, options); + } + + mkdirSync(path, options) { + 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 = ''; + + for (const segment of segments) { + currentPath = 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); + } else if (!entry.isDirectory()) { + throw createENOTDIR('mkdir', path); + } + current = entry; + } + } else { + const parent = this._ensureParent(normalized, false, 'mkdir'); + const name = this._getBaseName(normalized); + const entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new SafeMap(); + parent.children.set(name, entry); + } + + return recursive ? normalized : undefined; + } + + async mkdir(path, options) { + return this.mkdirSync(path, options); + } + + rmdirSync(path) { + const normalized = this._normalizePath(path); + const entry = this._getEntry(normalized, 'rmdir', true); + + 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 = this._getBaseName(normalized); + parent.children.delete(name); + } + + async rmdir(path) { + this.rmdirSync(path); + } + + unlinkSync(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 = this._getBaseName(normalized); + parent.children.delete(name); + } + + async unlink(path) { + this.unlinkSync(path); + } + + renameSync(oldPath, newPath) { + 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); + + // Remove from old location + const oldParent = this._ensureParent(normalizedOld, false, 'rename'); + const oldName = this._getBaseName(normalizedOld); + oldParent.children.delete(oldName); + + // Add to new location + const newParent = this._ensureParent(normalizedNew, true, 'rename'); + const newName = this._getBaseName(normalizedNew); + newParent.children.set(newName, entry); + } + + async rename(oldPath, newPath) { + this.renameSync(oldPath, 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) { + 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, true, 'symlink'); + const name = this._getBaseName(normalized); + const entry = new MemoryEntry(TYPE_SYMLINK); + entry.target = target; + parent.children.set(name, entry); + } + + 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); + } +} + +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..bf45a03272653a --- /dev/null +++ b/lib/internal/vfs/providers/real.js @@ -0,0 +1,376 @@ +'use strict'; + +const { + 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'); + +/** + * A file handle that wraps a real file descriptor. + */ +class RealFileHandle extends VirtualFileHandle { + #fd; + #realPath; + + /** + * @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(); + return fs.readSync(this.#fd, buffer, offset, length, position); + } + + async read(buffer, offset, length, position) { + this._checkClosed(); + 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(); + return fs.writeSync(this.#fd, buffer, offset, length, position); + } + + async write(buffer, offset, length, position) { + this._checkClosed(); + 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(); + return fs.readFileSync(this.#realPath, options); + } + + async readFile(options) { + this._checkClosed(); + return fs.promises.readFile(this.#realPath, options); + } + + writeFileSync(data, options) { + this._checkClosed(); + fs.writeFileSync(this.#realPath, data, options); + } + + async writeFile(data, options) { + this._checkClosed(); + return fs.promises.writeFile(this.#realPath, data, options); + } + + statSync(options) { + this._checkClosed(); + return fs.fstatSync(this.#fd, options); + } + + async stat(options) { + this._checkClosed(); + return new Promise((resolve, reject) => { + fs.fstat(this.#fd, options, (err, stats) => { + if (err) reject(err); + else resolve(stats); + }); + }); + } + + truncateSync(len = 0) { + this._checkClosed(); + fs.ftruncateSync(this.#fd, len); + } + + async truncate(len = 0) { + this._checkClosed(); + 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); + } + + /** + * Gets the root path of this provider. + * @returns {string} + */ + get rootPath() { + return this.#rootPath; + } + + get readonly() { + return false; + } + + get supportsSymlinks() { + return true; + } + + /** + * 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) { + // 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)) { + const { createENOENT } = require('internal/vfs/errors'); + throw createENOENT('open', vfsPath); + } + + return realPath; + } + + 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); + return fs.lstatSync(realPath, options); + } + + async lstat(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + 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); + return fs.readlinkSync(realPath, options); + } + + async readlink(vfsPath, options) { + const realPath = this._resolvePath(vfsPath); + return fs.promises.readlink(realPath, options); + } + + symlinkSync(target, vfsPath, type) { + const realPath = this._resolvePath(vfsPath); + fs.symlinkSync(target, realPath, type); + } + + async symlink(target, vfsPath, type) { + const realPath = this._resolvePath(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 (shouldn't happen normally) + return 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, '/'); + } + return 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); + } +} + +module.exports = { + RealFSProvider, + RealFileHandle, +}; diff --git a/lib/internal/vfs/providers/sea.js b/lib/internal/vfs/providers/sea.js new file mode 100644 index 00000000000000..7d55a41334da7c --- /dev/null +++ b/lib/internal/vfs/providers/sea.js @@ -0,0 +1,429 @@ +'use strict'; + +const { + ArrayPrototypePush, + Boolean, + MathMin, + SafeMap, + SafeSet, + StringPrototypeStartsWith, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { VirtualFileHandle } = require('internal/vfs/file_handle'); +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { + createENOENT, + createENOTDIR, + createEISDIR, + createEROFS, +} = require('internal/vfs/errors'); +const { + createFileStats, + createDirectoryStats, +} = require('internal/vfs/stats'); +const { Dirent } = require('internal/fs/utils'); +const { + fs: { + UV_DIRENT_FILE, + UV_DIRENT_DIR, + }, +} = internalBinding('constants'); + +// Private symbols +const kAssets = Symbol('kAssets'); +const kDirectories = Symbol('kDirectories'); +const kGetAsset = Symbol('kGetAsset'); + +/** + * File handle for SEA assets (read-only). + */ +class SEAFileHandle extends VirtualFileHandle { + #content; + #getStats; + + /** + * @param {string} path The file path + * @param {Buffer} content The file content + * @param {Function} getStats Function to get stats + */ + constructor(path, content, getStats) { + super(path, 'r', 0o444); + this.#content = content; + this.#getStats = getStats; + } + + readSync(buffer, offset, length, position) { + this._checkClosed(); + + const readPos = position !== null && position !== undefined ? position : this.position; + const available = this.#content.length - readPos; + + if (available <= 0) { + return 0; + } + + const bytesToRead = MathMin(length, available); + this.#content.copy(buffer, offset, readPos, readPos + bytesToRead); + + if (position === null || position === undefined) { + this.position = readPos + bytesToRead; + } + + return bytesToRead; + } + + async read(buffer, offset, length, position) { + const bytesRead = this.readSync(buffer, offset, length, position); + return { __proto__: null, bytesRead, buffer }; + } + + readFileSync(options) { + this._checkClosed(); + + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return this.#content.toString(encoding); + } + return Buffer.from(this.#content); + } + + async readFile(options) { + return this.readFileSync(options); + } + + writeSync() { + throw createEROFS('write', this.path); + } + + async write() { + throw createEROFS('write', this.path); + } + + writeFileSync() { + throw createEROFS('write', this.path); + } + + async writeFile() { + throw createEROFS('write', this.path); + } + + truncateSync() { + throw createEROFS('ftruncate', this.path); + } + + async truncate() { + throw createEROFS('ftruncate', this.path); + } + + statSync(options) { + this._checkClosed(); + return this.#getStats(); + } + + async stat(options) { + return this.statSync(options); + } +} + +/** + * Read-only provider for Single Executable Application (SEA) assets. + * Assets are accessed via sea.getAsset() binding. + */ +class SEAProvider extends VirtualProvider { + /** + * @param {object} [options] Options + */ + constructor(options = {}) { + super(); + + // Lazy-load SEA bindings + const { isSea, getAsset, getAssetKeys } = internalBinding('sea'); + + if (!isSea()) { + throw new ERR_INVALID_STATE('SEAProvider can only be used in a Single Executable Application'); + } + + this[kGetAsset] = getAsset; + + // Build asset map and derive directory structure + this[kAssets] = new SafeMap(); + this[kDirectories] = new SafeMap(); + + // Root directory always exists + this[kDirectories].set('/', new SafeSet()); + + const keys = getAssetKeys() || []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + // Normalize key to path + const path = StringPrototypeStartsWith(key, '/') ? key : `/${key}`; + this[kAssets].set(path, key); + + // Derive parent directories + const parts = path.split('/').filter(Boolean); + let currentPath = ''; + for (let j = 0; j < parts.length - 1; j++) { + const parentPath = currentPath || '/'; + currentPath = currentPath + '/' + parts[j]; + + if (!this[kDirectories].has(currentPath)) { + this[kDirectories].set(currentPath, new SafeSet()); + } + + // Add this directory to parent's children + const parentChildren = this[kDirectories].get(parentPath); + if (parentChildren) { + parentChildren.add(parts[j]); + } + } + + // Add file to parent directory's children + if (parts.length > 0) { + const fileName = parts[parts.length - 1]; + const parentPath = parts.length === 1 ? '/' : '/' + parts.slice(0, -1).join('/'); + + if (!this[kDirectories].has(parentPath)) { + this[kDirectories].set(parentPath, new SafeSet()); + } + + this[kDirectories].get(parentPath).add(fileName); + } + } + } + + get readonly() { + return true; + } + + get supportsSymlinks() { + return false; + } + + /** + * Normalizes a path. + * @param {string} path The path + * @returns {string} Normalized path + */ + _normalizePath(path) { + let normalized = path.replace(/\\/g, '/'); + if (normalized !== '/' && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + return normalized; + } + + /** + * Checks if a path is a file. + * @param {string} path Normalized path + * @returns {boolean} + */ + _isFile(path) { + return this[kAssets].has(path); + } + + /** + * Checks if a path is a directory. + * @param {string} path Normalized path + * @returns {boolean} + */ + _isDirectory(path) { + return this[kDirectories].has(path); + } + + /** + * Gets the asset content. + * @param {string} path Normalized path + * @returns {Buffer} + */ + _getAssetContent(path) { + const key = this[kAssets].get(path); + if (!key) { + throw createENOENT('open', path); + } + const content = this[kGetAsset](key); + return Buffer.from(content); + } + + openSync(path, flags, mode) { + // Only allow read modes + if (flags !== 'r') { + throw createEROFS('open', path); + } + + const normalized = this._normalizePath(path); + + if (this._isDirectory(normalized)) { + throw createEISDIR('open', path); + } + + if (!this._isFile(normalized)) { + throw createENOENT('open', path); + } + + const content = this._getAssetContent(normalized); + const getStats = () => createFileStats(content.length, { mode: 0o444 }); + + return new SEAFileHandle(normalized, content, getStats); + } + + async open(path, flags, mode) { + return this.openSync(path, flags, mode); + } + + statSync(path, options) { + const normalized = this._normalizePath(path); + + if (this._isDirectory(normalized)) { + return createDirectoryStats({ mode: 0o555 }); + } + + if (this._isFile(normalized)) { + const content = this._getAssetContent(normalized); + return createFileStats(content.length, { mode: 0o444 }); + } + + throw createENOENT('stat', path); + } + + async stat(path, options) { + return this.statSync(path, options); + } + + lstatSync(path, options) { + // No symlinks, same as stat + return this.statSync(path, options); + } + + async lstat(path, options) { + return this.lstatSync(path, options); + } + + readdirSync(path, options) { + const normalized = this._normalizePath(path); + + if (!this._isDirectory(normalized)) { + if (this._isFile(normalized)) { + throw createENOTDIR('scandir', path); + } + throw createENOENT('scandir', path); + } + + const children = this[kDirectories].get(normalized); + const names = [...children]; + + if (options?.withFileTypes) { + const dirents = []; + for (const name of names) { + const childPath = normalized === '/' ? `/${name}` : `${normalized}/${name}`; + let type; + if (this._isDirectory(childPath)) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; + } + ArrayPrototypePush(dirents, new Dirent(name, type, normalized)); + } + return dirents; + } + + return names; + } + + async readdir(path, options) { + return this.readdirSync(path, options); + } + + mkdirSync(path, options) { + throw createEROFS('mkdir', path); + } + + async mkdir(path, options) { + throw createEROFS('mkdir', path); + } + + rmdirSync(path) { + throw createEROFS('rmdir', path); + } + + async rmdir(path) { + throw createEROFS('rmdir', path); + } + + unlinkSync(path) { + throw createEROFS('unlink', path); + } + + async unlink(path) { + throw createEROFS('unlink', path); + } + + renameSync(oldPath, newPath) { + throw createEROFS('rename', oldPath); + } + + async rename(oldPath, newPath) { + throw createEROFS('rename', oldPath); + } + + readFileSync(path, options) { + const normalized = this._normalizePath(path); + + if (this._isDirectory(normalized)) { + throw createEISDIR('read', path); + } + + if (!this._isFile(normalized)) { + throw createENOENT('open', path); + } + + const content = this._getAssetContent(normalized); + + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return content; + } + + async readFile(path, options) { + return this.readFileSync(path, options); + } + + writeFileSync(path, data, options) { + throw createEROFS('open', path); + } + + async writeFile(path, data, options) { + throw createEROFS('open', path); + } + + appendFileSync(path, data, options) { + throw createEROFS('open', path); + } + + async appendFile(path, data, options) { + throw createEROFS('open', path); + } + + copyFileSync(src, dest, mode) { + throw createEROFS('copyfile', dest); + } + + async copyFile(src, dest, mode) { + throw createEROFS('copyfile', dest); + } +} + +module.exports = { + SEAProvider, +}; diff --git a/lib/internal/vfs/router.js b/lib/internal/vfs/router.js new file mode 100644 index 00000000000000..6593e365c054d8 --- /dev/null +++ b/lib/internal/vfs/router.js @@ -0,0 +1,147 @@ +'use strict'; + +const { + StringPrototypeEndsWith, + StringPrototypeLastIndexOf, + StringPrototypeReplaceAll, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeStartsWith, +} = primordials; + +const { basename, isAbsolute, resolve, sep } = require('path'); + +/** + * Normalizes a path for VFS lookup. + * - Resolves to absolute path + * - Removes trailing slashes (except for root) + * - Normalizes separators to forward slashes (VFS uses '/' internally) + * @param {string} inputPath The path to normalize + * @returns {string} The normalized path + */ +function normalizePath(inputPath) { + let normalized = resolve(inputPath); + + // On Windows, convert backslashes to forward slashes for consistent VFS lookup. + // VFS uses forward slashes internally regardless of platform. + if (sep === '\\') { + normalized = StringPrototypeReplaceAll(normalized, '\\', '/'); + } + + // Remove trailing slash (except for root) + if (normalized.length > 1 && StringPrototypeEndsWith(normalized, '/')) { + normalized = StringPrototypeSlice(normalized, 0, -1); + } + + return normalized; +} + +/** + * Splits a path into segments. + * VFS paths are always normalized to use forward slashes. + * @param {string} normalizedPath A normalized absolute path + * @returns {string[]} Path segments + */ +function splitPath(normalizedPath) { + if (normalizedPath === '/') { + return []; + } + // Remove leading slash and split by forward slash (VFS internal format) + return StringPrototypeSplit(StringPrototypeSlice(normalizedPath, 1), '/'); +} + +/** + * Gets the parent path of a normalized path. + * VFS paths are always normalized to use forward slashes. + * @param {string} normalizedPath A normalized absolute path + * @returns {string|null} The parent path, or null if at root + */ +function getParentPath(normalizedPath) { + if (normalizedPath === '/') { + return null; + } + const lastSlash = StringPrototypeLastIndexOf(normalizedPath, '/'); + if (lastSlash === 0) { + return '/'; + } + return StringPrototypeSlice(normalizedPath, 0, lastSlash); +} + +/** + * Gets the base name from a normalized path. + * @param {string} normalizedPath A normalized absolute path + * @returns {string} The base name + */ +function getBaseName(normalizedPath) { + // Basename works correctly for VFS paths since they use forward slashes + return basename(normalizedPath); +} + +/** + * Checks if a path is under a mount point. + * Note: We don't use path.relative here because VFS mount point semantics + * require exact prefix matching with the forward-slash separator. + * @param {string} normalizedPath A normalized absolute path + * @param {string} mountPoint A normalized mount point path + * @returns {boolean} + */ +function isUnderMountPoint(normalizedPath, mountPoint) { + if (normalizedPath === mountPoint) { + return true; + } + // Special case: root mount point - all absolute paths are under it + if (mountPoint === '/') { + return StringPrototypeStartsWith(normalizedPath, '/'); + } + // Path must start with mountPoint followed by a slash + return StringPrototypeStartsWith(normalizedPath, mountPoint + '/'); +} + +/** + * Gets the relative path from a mount point. + * Note: We don't use path.relative here because we need the VFS-internal + * path format (starting with /) for consistent lookup. + * @param {string} normalizedPath A normalized absolute path + * @param {string} mountPoint A normalized mount point path + * @returns {string} The relative path (starting with /) + */ +function getRelativePath(normalizedPath, mountPoint) { + if (normalizedPath === mountPoint) { + return '/'; + } + // Special case: root mount point - the path is already the relative path + if (mountPoint === '/') { + return normalizedPath; + } + return StringPrototypeSlice(normalizedPath, mountPoint.length); +} + +/** + * Joins a mount point with a relative path. + * Note: We don't use path.join here because VFS relative paths start with / + * and path.join would treat them as absolute paths. + * @param {string} mountPoint A normalized mount point path + * @param {string} relativePath A relative path (starting with /) + * @returns {string} The joined absolute path + */ +function joinMountPath(mountPoint, relativePath) { + if (relativePath === '/') { + return mountPoint; + } + // Special case: root mount point - the relative path is already the full path + if (mountPoint === '/') { + return relativePath; + } + return mountPoint + relativePath; +} + +module.exports = { + normalizePath, + splitPath, + getParentPath, + getBaseName, + isUnderMountPoint, + getRelativePath, + joinMountPath, + isAbsolutePath: isAbsolute, +}; diff --git a/lib/internal/vfs/sea.js b/lib/internal/vfs/sea.js new file mode 100644 index 00000000000000..5b319ccd1a0339 --- /dev/null +++ b/lib/internal/vfs/sea.js @@ -0,0 +1,75 @@ +'use strict'; + +const { isSea } = internalBinding('sea'); +const { kEmptyObject, getLazy } = require('internal/util'); + +// Lazy-loaded VFS +let cachedSeaVfs = null; + +// Lazy-load VirtualFileSystem and SEAProvider to avoid loading VFS code if not needed +const lazyVirtualFileSystem = getLazy( + () => require('internal/vfs/file_system').VirtualFileSystem, +); +const lazySEAProvider = getLazy( + () => require('internal/vfs/providers/sea').SEAProvider, +); + +/** + * Creates a VirtualFileSystem populated with SEA assets using the new Provider architecture. + * Assets are mounted at the specified prefix (default: '/sea'). + * @param {object} [options] Configuration options + * @param {string} [options.prefix] Mount point prefix for SEA assets + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks + * @returns {VirtualFileSystem|null} The VFS instance, or null if not running as SEA + */ +function createSeaVfs(options = kEmptyObject) { + if (!isSea()) { + return null; + } + + const VirtualFileSystem = lazyVirtualFileSystem(); + const SEAProvider = lazySEAProvider(); + + const prefix = options.prefix ?? '/sea'; + const moduleHooks = options.moduleHooks !== false; + + const provider = new SEAProvider(); + const vfs = new VirtualFileSystem(provider, { moduleHooks }); + + // Mount at the specified prefix + vfs.mount(prefix); + + return vfs; +} + +/** + * Gets or creates the default SEA VFS instance. + * This is a singleton that is lazily created on first access. + * @param {object} [options] Configuration options (only used on first call) + * @returns {VirtualFileSystem|null} The VFS instance, or null if not running as SEA + */ +function getSeaVfs(options) { + if (cachedSeaVfs === null) { + cachedSeaVfs = createSeaVfs(options); + } + return cachedSeaVfs; +} + +/** + * Checks if SEA VFS is available (i.e., running as SEA with assets). + * @returns {boolean} + */ +function hasSeaAssets() { + if (!isSea()) { + return false; + } + const { getAssetKeys } = internalBinding('sea'); + const keys = getAssetKeys() || []; + return keys.length > 0; +} + +module.exports = { + createSeaVfs, + getSeaVfs, + hasSeaAssets, +}; diff --git a/lib/internal/vfs/stats.js b/lib/internal/vfs/stats.js new file mode 100644 index 00000000000000..9ebc44fbb385b5 --- /dev/null +++ b/lib/internal/vfs/stats.js @@ -0,0 +1,196 @@ +'use strict'; + +const { + 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; + +// Reusable Float64Array for creating Stats objects +// 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); + +/** + * 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 }; +} + +/** + * 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 + * @returns {Stats} + */ +function createFileStats(size, options = {}) { + const now = DateNow(); + const mode = (options.mode ?? 0o644) | S_IFREG; + 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 atime = msToTimeSpec(atimeMs); + const mtime = msToTimeSpec(mtimeMs); + const ctime = msToTimeSpec(ctimeMs); + const birthtime = msToTimeSpec(birthtimeMs); + + statsArray[0] = 0; // 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] = 0; // 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 atime = msToTimeSpec(atimeMs); + const mtime = msToTimeSpec(mtimeMs); + const ctime = msToTimeSpec(ctimeMs); + const birthtime = msToTimeSpec(birthtimeMs); + + statsArray[0] = 0; // 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] = 0; // 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 atime = msToTimeSpec(atimeMs); + const mtime = msToTimeSpec(mtimeMs); + const ctime = msToTimeSpec(ctimeMs); + const birthtime = msToTimeSpec(birthtimeMs); + + statsArray[0] = 0; // 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] = 0; // 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); +} + +module.exports = { + createFileStats, + createDirectoryStats, + createSymlinkStats, + kDefaultBlockSize, +}; diff --git a/lib/internal/vfs/streams.js b/lib/internal/vfs/streams.js new file mode 100644 index 00000000000000..06aafb9e7ad98a --- /dev/null +++ b/lib/internal/vfs/streams.js @@ -0,0 +1,163 @@ +'use strict'; + +const { + MathMin, +} = primordials; + +const { Readable } = require('stream'); +const { createEBADF } = require('internal/vfs/errors'); + +/** + * A readable stream for virtual files. + */ +class VirtualReadStream extends Readable { + #vfs; + #path; + #fd = null; + #end; + #pos; + #content = null; + #destroyed = false; + #autoClose; + + /** + * @param {VirtualFileSystem} vfs The VFS instance + * @param {string} filePath The path to the file + * @param {object} [options] Stream options + */ + constructor(vfs, filePath, options = {}) { + const { + start = 0, + end = Infinity, + highWaterMark = 64 * 1024, + encoding, + ...streamOptions + } = options; + + super({ ...streamOptions, highWaterMark, encoding }); + + this.#vfs = vfs; + this.#path = filePath; + this.#end = end; + this.#pos = start; + this.#autoClose = options.autoClose !== false; + + // Open the file on next tick so listeners can be attached + 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) { + return; + } + + // Load content on first read (lazy loading) + if (this.#content === null) { + try { + const vfd = require('internal/vfs/fd').getVirtualFd(this.#fd); + if (!vfd) { + this.destroy(createEBADF('read')); + return; + } + // Use the file handle's readFileSync to get content + this.#content = vfd.entry.readFileSync(); + } 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); + // Note: #close() will be called by _destroy() when autoClose is true + return; + } + + const bytesToRead = MathMin(size, remaining); + const chunk = this.#content.subarray(this.#pos, this.#pos + bytesToRead); + this.#pos += bytesToRead; + + this.push(chunk); + + // Check if we've reached the end + if (this.#pos >= endPos || this.#pos >= this.#content.length) { + this.push(null); + // Note: #close() will be called by _destroy() when autoClose is true + } + } + + /** + * 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) { + this.#destroyed = true; + if (this.#autoClose) { + this.#close(); + } + callback(err); + } +} + +/** + * Creates a readable stream for a virtual file. + * @param {VirtualFileSystem} vfs The VFS instance + * @param {string} filePath The path to the file + * @param {object} [options] Stream options + * @returns {VirtualReadStream} + */ +function createVirtualReadStream(vfs, filePath, options = {}) { + return new VirtualReadStream(vfs, filePath, options); +} + +module.exports = { + createVirtualReadStream, + VirtualReadStream, +}; diff --git a/lib/internal/vfs/virtual_fs.js b/lib/internal/vfs/virtual_fs.js new file mode 100644 index 00000000000000..d8e4263f14fd7c --- /dev/null +++ b/lib/internal/vfs/virtual_fs.js @@ -0,0 +1,1348 @@ +'use strict'; + +const { + ArrayPrototypePush, + MathMin, + ObjectFreeze, + Symbol, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_VALUE, + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { + VirtualFile, + VirtualDirectory, + VirtualSymlink, +} = require('internal/vfs/entries'); +const { + normalizePath, + splitPath, + getParentPath, + getBaseName, + isUnderMountPoint, + getRelativePath, + isAbsolutePath, +} = require('internal/vfs/router'); +const { + createENOENT, + createENOTDIR, + createEISDIR, + createEBADF, + createELOOP, + createEINVAL, +} = require('internal/vfs/errors'); +const { + openVirtualFd, + getVirtualFd, + closeVirtualFd, + isVirtualFd, +} = require('internal/vfs/fd'); +const { createVirtualReadStream } = require('internal/vfs/streams'); +const { Dirent } = require('internal/fs/utils'); +const { + registerVFS, + unregisterVFS, +} = require('internal/vfs/module_hooks'); +const { emitExperimentalWarning } = require('internal/util'); +const { + fs: { + UV_DIRENT_FILE, + UV_DIRENT_DIR, + UV_DIRENT_LINK, + }, +} = internalBinding('constants'); + +// Maximum symlink resolution depth to prevent infinite loops +const kMaxSymlinkDepth = 40; + +// Private symbols +const kRoot = Symbol('kRoot'); +const kMountPoint = Symbol('kMountPoint'); +const kMounted = Symbol('kMounted'); +const kOverlay = Symbol('kOverlay'); +const kFallthrough = Symbol('kFallthrough'); +const kModuleHooks = Symbol('kModuleHooks'); +const kPromises = Symbol('kPromises'); +const kVirtualCwd = Symbol('kVirtualCwd'); +const kVirtualCwdEnabled = Symbol('kVirtualCwdEnabled'); +const kOriginalChdir = Symbol('kOriginalChdir'); +const kOriginalCwd = Symbol('kOriginalCwd'); + +/** + * Virtual File System implementation. + * Provides an in-memory file system that can be mounted at a path or used as an overlay. + */ +class VirtualFileSystem { + /** + * @param {object} [options] Configuration options + * @param {boolean} [options.fallthrough] Whether to fall through to real fs on miss + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks + * @param {boolean} [options.virtualCwd] Whether to enable virtual working directory + */ + constructor(options = {}) { + emitExperimentalWarning('fs.createVirtual'); + this[kRoot] = new VirtualDirectory('/'); + this[kMountPoint] = null; + this[kMounted] = false; + this[kOverlay] = false; + this[kFallthrough] = options.fallthrough !== false; + this[kModuleHooks] = options.moduleHooks !== false; + this[kPromises] = null; // Lazy-initialized + this[kVirtualCwdEnabled] = options.virtualCwd === true; + this[kVirtualCwd] = null; // Set when chdir() is called + this[kOriginalChdir] = null; // Saved process.chdir + this[kOriginalCwd] = null; // Saved process.cwd + } + + /** + * Gets the mount point path, or null if not mounted. + * @returns {string|null} + */ + get mountPoint() { + return this[kMountPoint]; + } + + /** + * Returns true if VFS is mounted. + * @returns {boolean} + */ + get mounted() { + return this[kMounted]; + } + + /** + * Returns true if VFS is in overlay mode. + * @returns {boolean} + */ + get isOverlay() { + return this[kOverlay]; + } + + /** + * Returns true if VFS falls through to real fs on miss. + * @returns {boolean} + */ + get fallthrough() { + return this[kFallthrough]; + } + + /** + * Returns true if virtual working directory is enabled. + * @returns {boolean} + */ + get virtualCwdEnabled() { + return this[kVirtualCwdEnabled]; + } + + // ==================== Virtual Working Directory ==================== + + /** + * Gets the virtual current working directory. + * Returns null if no virtual cwd is set. + * @returns {string|null} + */ + cwd() { + if (!this[kVirtualCwdEnabled]) { + throw new ERR_INVALID_STATE('virtual cwd is not enabled'); + } + return this[kVirtualCwd]; + } + + /** + * Sets the virtual current working directory. + * The path must exist in the VFS. + * @param {string} dirPath The directory path to set as cwd + */ + chdir(dirPath) { + if (!this[kVirtualCwdEnabled]) { + throw new ERR_INVALID_STATE('virtual cwd is not enabled'); + } + + const normalized = normalizePath(dirPath); + const entry = this._resolveEntry(normalized); + + if (!entry) { + throw createENOENT('chdir', normalized); + } + + if (!entry.isDirectory()) { + throw createENOTDIR('chdir', normalized); + } + + this[kVirtualCwd] = normalized; + } + + /** + * Resolves a path relative to the virtual cwd if set. + * If the path is absolute or no virtual cwd is set, returns the path as-is. + * @param {string} inputPath The path to resolve + * @returns {string} The resolved path + */ + resolvePath(inputPath) { + // If path is absolute, return as-is (works for both Unix / and Windows C:\) + if (isAbsolutePath(inputPath)) { + return normalizePath(inputPath); + } + + // If virtual cwd is enabled and set, resolve relative to it + if (this[kVirtualCwdEnabled] && this[kVirtualCwd] !== null) { + const resolved = this[kVirtualCwd] + '/' + inputPath; + return normalizePath(resolved); + } + + // Fall back to normalizing the path (will use real cwd) + return normalizePath(inputPath); + } + + // ==================== Entry Management ==================== + + /** + * Adds a file to the VFS. + * @param {string} filePath The absolute path for the file + * @param {Buffer|string|Function} content The file content or content provider + * @param {object} [options] Optional configuration + */ + addFile(filePath, content, options) { + const normalized = normalizePath(filePath); + const segments = splitPath(normalized); + + if (segments.length === 0) { + throw new ERR_INVALID_ARG_VALUE('filePath', filePath, 'cannot be root path'); + } + + // Ensure parent directories exist + let current = this[kRoot]; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + let entry = current.getEntry(segment); + if (!entry) { + // Auto-create parent directory + const dirPath = '/' + segments.slice(0, i + 1).join('/'); + entry = new VirtualDirectory(dirPath); + current.addEntry(segment, entry); + } else if (!entry.isDirectory()) { + throw new ERR_INVALID_ARG_VALUE('filePath', filePath, `path segment '${segments.slice(0, i + 1).join('/')}' is not a directory`); + } + current = entry; + } + + // Add the file + const fileName = segments[segments.length - 1]; + const file = new VirtualFile(normalized, content, options); + current.addEntry(fileName, file); + } + + /** + * Adds a directory to the VFS. + * @param {string} dirPath The absolute path for the directory + * @param {Function} [populate] Optional callback to populate directory contents + * @param {object} [options] Optional configuration + */ + addDirectory(dirPath, populate, options) { + const normalized = normalizePath(dirPath); + const segments = splitPath(normalized); + + // Handle root directory + if (segments.length === 0) { + if (typeof populate === 'function') { + // Replace root with dynamic directory + this[kRoot] = new VirtualDirectory('/', populate, options); + } + return; + } + + // Ensure parent directories exist + let current = this[kRoot]; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + let entry = current.getEntry(segment); + if (!entry) { + // Auto-create parent directory + const parentPath = '/' + segments.slice(0, i + 1).join('/'); + entry = new VirtualDirectory(parentPath); + current.addEntry(segment, entry); + } else if (!entry.isDirectory()) { + throw new ERR_INVALID_ARG_VALUE('dirPath', dirPath, `path segment '${segments.slice(0, i + 1).join('/')}' is not a directory`); + } + current = entry; + } + + // Add the directory + const dirName = segments[segments.length - 1]; + const dir = new VirtualDirectory(normalized, populate, options); + current.addEntry(dirName, dir); + } + + /** + * Adds a symbolic link to the VFS. + * @param {string} linkPath The absolute path for the symlink + * @param {string} target The symlink target (can be relative or absolute) + * @param {object} [options] Optional configuration + */ + addSymlink(linkPath, target, options) { + const normalized = normalizePath(linkPath); + const segments = splitPath(normalized); + + if (segments.length === 0) { + throw new ERR_INVALID_ARG_VALUE('linkPath', linkPath, 'cannot be root path'); + } + + // Ensure parent directories exist + let current = this[kRoot]; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + let entry = current.getEntry(segment); + if (!entry) { + // Auto-create parent directory + const parentPath = '/' + segments.slice(0, i + 1).join('/'); + entry = new VirtualDirectory(parentPath); + current.addEntry(segment, entry); + } else if (!entry.isDirectory()) { + throw new ERR_INVALID_ARG_VALUE('linkPath', linkPath, `path segment '${segments.slice(0, i + 1).join('/')}' is not a directory`); + } + current = entry; + } + + // Add the symlink + const linkName = segments[segments.length - 1]; + const symlink = new VirtualSymlink(normalized, target, options); + current.addEntry(linkName, symlink); + } + + /** + * Removes an entry from the VFS. + * @param {string} entryPath The absolute path to remove + * @returns {boolean} True if the entry was removed + */ + remove(entryPath) { + const normalized = normalizePath(entryPath); + const parentPath = getParentPath(normalized); + + if (parentPath === null) { + // Cannot remove root + return false; + } + + const parent = this._resolveEntry(parentPath); + if (!parent || !parent.isDirectory()) { + return false; + } + + const name = getBaseName(normalized); + return parent.removeEntry(name); + } + + /** + * Checks if a path exists in the VFS. + * @param {string} entryPath The absolute path to check + * @returns {boolean} + */ + has(entryPath) { + const normalized = normalizePath(entryPath); + return this._resolveEntry(normalized) !== null; + } + + // ==================== Mount/Overlay ==================== + + /** + * Mounts the VFS at a specific path prefix. + * @param {string} prefix The mount point path + */ + mount(prefix) { + if (this[kMounted] || this[kOverlay]) { + throw new ERR_INVALID_STATE('VFS is already mounted or in overlay mode'); + } + this[kMountPoint] = normalizePath(prefix); + this[kMounted] = true; + if (this[kModuleHooks]) { + registerVFS(this); + } + if (this[kVirtualCwdEnabled]) { + this._hookProcessCwd(); + } + } + + /** + * Enables overlay mode (intercepts all matching paths). + */ + overlay() { + if (this[kMounted] || this[kOverlay]) { + throw new ERR_INVALID_STATE('VFS is already mounted or in overlay mode'); + } + this[kOverlay] = true; + if (this[kModuleHooks]) { + registerVFS(this); + } + if (this[kVirtualCwdEnabled]) { + this._hookProcessCwd(); + } + } + + /** + * Unmounts the VFS. + */ + unmount() { + this._unhookProcessCwd(); + unregisterVFS(this); + this[kMountPoint] = null; + this[kMounted] = false; + this[kOverlay] = false; + this[kVirtualCwd] = null; // Reset virtual cwd on unmount + } + + /** + * Hooks process.chdir and process.cwd to support virtual cwd. + * @private + */ + _hookProcessCwd() { + if (this[kOriginalChdir] !== null) { + // Already hooked + return; + } + + const vfs = this; + + // Save original process methods + this[kOriginalChdir] = process.chdir; + this[kOriginalCwd] = process.cwd; + + // Override process.chdir + process.chdir = function chdir(directory) { + const normalized = normalizePath(directory); + + // Check if this path is within VFS + if (vfs.shouldHandle(normalized)) { + vfs.chdir(normalized); + return; + } + + // Fall through to real chdir + return vfs[kOriginalChdir].call(process, directory); + }; + + // Override process.cwd + process.cwd = function cwd() { + // If virtual cwd is set, return it + if (vfs[kVirtualCwd] !== null) { + return vfs[kVirtualCwd]; + } + + // Fall through to real cwd + return vfs[kOriginalCwd].call(process); + }; + } + + /** + * Restores original process.chdir and process.cwd. + * @private + */ + _unhookProcessCwd() { + if (this[kOriginalChdir] === null) { + // Not hooked + return; + } + + // Restore original process methods + process.chdir = this[kOriginalChdir]; + process.cwd = this[kOriginalCwd]; + + this[kOriginalChdir] = null; + this[kOriginalCwd] = null; + } + + // ==================== Path Resolution ==================== + + /** + * Resolves a symlink target path to its final entry. + * @param {VirtualSymlink} symlink The symlink to resolve + * @param {string} symlinkPath The absolute path of the symlink + * @param {number} depth Current resolution depth + * @returns {{ entry: VirtualEntry|null, resolvedPath: string|null }} + * @private + */ + _resolveSymlinkTarget(symlink, symlinkPath, depth) { + if (depth >= kMaxSymlinkDepth) { + return { entry: null, resolvedPath: null, eloop: true }; + } + + const target = symlink.target; + let targetPath; + + if (isAbsolutePath(target)) { + // Absolute symlink target - interpret as VFS-internal path + // If mounted, prepend the mount point to get the actual filesystem path + // Normalize first to handle Windows paths (convert \ to /) + const normalizedTarget = normalizePath(target); + if (this[kMounted] && this[kMountPoint]) { + // For VFS-internal absolute paths starting with /, prepend mount point + // For external absolute paths (like C:/...), use as-is + if (target.startsWith('/')) { + targetPath = this[kMountPoint] + normalizedTarget; + } else { + targetPath = normalizedTarget; + } + } else { + targetPath = normalizedTarget; + } + } else { + // Relative symlink target - resolve relative to symlink's parent directory + const parentPath = getParentPath(symlinkPath); + if (parentPath === null) { + targetPath = '/' + target; + } else { + targetPath = parentPath + '/' + target; + } + } + + // Resolve the target path (which may contain more symlinks) + return this._resolveEntryInternal(targetPath, true, depth + 1); + } + + /** + * Internal method to resolve a path to its VFS entry. + * @param {string} inputPath The path to resolve + * @param {boolean} followSymlinks Whether to follow symlinks + * @param {number} depth Current symlink resolution depth + * @returns {{ entry: VirtualEntry|null, resolvedPath: string|null, eloop?: boolean }} + * @private + */ + _resolveEntryInternal(inputPath, followSymlinks, depth) { + const normalized = normalizePath(inputPath); + + // Determine the path within VFS + let vfsPath; + if (this[kMounted] && this[kMountPoint]) { + if (!isUnderMountPoint(normalized, this[kMountPoint])) { + return { entry: null, resolvedPath: null }; + } + vfsPath = getRelativePath(normalized, this[kMountPoint]); + } else { + vfsPath = normalized; + } + + // Handle root + if (vfsPath === '/') { + return { entry: this[kRoot], resolvedPath: normalized }; + } + + // Walk the path + const segments = splitPath(vfsPath); + let current = this[kRoot]; + let currentPath = this[kMountPoint] || ''; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Follow symlinks for intermediate path components + if (current.isSymbolicLink() && followSymlinks) { + const result = this._resolveSymlinkTarget(current, currentPath, depth); + if (result.eloop) { + return { entry: null, resolvedPath: null, eloop: true }; + } + if (!result.entry) { + return { entry: null, resolvedPath: null }; + } + current = result.entry; + currentPath = result.resolvedPath; + } + + if (!current.isDirectory()) { + return { entry: null, resolvedPath: null }; + } + + const entry = current.getEntry(segment); + if (!entry) { + return { entry: null, resolvedPath: null }; + } + + currentPath = currentPath + '/' + segment; + current = entry; + } + + // Follow symlink at the end if requested + if (current.isSymbolicLink() && followSymlinks) { + const result = this._resolveSymlinkTarget(current, currentPath, depth); + if (result.eloop) { + return { entry: null, resolvedPath: null, eloop: true }; + } + return result; + } + + return { entry: current, resolvedPath: currentPath }; + } + + /** + * Resolves a path to its VFS entry, if it exists. + * Follows symlinks by default. + * @param {string} inputPath The path to resolve + * @param {boolean} [followSymlinks] Whether to follow symlinks + * @returns {VirtualEntry|null} + * @private + */ + _resolveEntry(inputPath, followSymlinks = true) { + const result = this._resolveEntryInternal(inputPath, followSymlinks, 0); + return result.entry; + } + + /** + * Resolves a path and throws ELOOP if symlink loop detected. + * @param {string} inputPath The path to resolve + * @param {string} syscall The syscall name for error + * @param {boolean} [followSymlinks] Whether to follow symlinks + * @returns {VirtualEntry} + * @throws {Error} ENOENT if path doesn't exist, ELOOP if symlink loop + * @private + */ + _resolveEntryOrThrow(inputPath, syscall, followSymlinks = true) { + const result = this._resolveEntryInternal(inputPath, followSymlinks, 0); + if (result.eloop) { + throw createELOOP(syscall, inputPath); + } + if (!result.entry) { + throw createENOENT(syscall, inputPath); + } + return result.entry; + } + + /** + * Checks if a path should be handled by this VFS. + * @param {string} inputPath The path to check + * @returns {boolean} + */ + shouldHandle(inputPath) { + if (!this[kMounted] && !this[kOverlay]) { + return false; + } + + const normalized = normalizePath(inputPath); + + if (this[kOverlay]) { + // In overlay mode, check if the path exists in VFS + return this._resolveEntry(normalized) !== null; + } + + if (this[kMounted] && this[kMountPoint]) { + // In mount mode, check if path is under mount point + return isUnderMountPoint(normalized, this[kMountPoint]); + } + + return false; + } + + // ==================== FS Operations (Sync) ==================== + + /** + * Checks if a path exists synchronously. + * @param {string} filePath The path to check + * @returns {boolean} + */ + existsSync(filePath) { + return this._resolveEntry(filePath) !== null; + } + + /** + * Gets stats for a path synchronously. + * Follows symlinks to get the target's stats. + * @param {string} filePath The path to stat + * @returns {Stats} + * @throws {Error} If path does not exist or symlink loop detected + */ + statSync(filePath) { + const entry = this._resolveEntryOrThrow(filePath, 'stat', true); + return entry.getStats(); + } + + /** + * Gets stats for a path synchronously without following symlinks. + * Returns the symlink's own stats if the path is a symlink. + * @param {string} filePath The path to stat + * @returns {Stats} + * @throws {Error} If path does not exist + */ + lstatSync(filePath) { + const entry = this._resolveEntryOrThrow(filePath, 'lstat', false); + return entry.getStats(); + } + + /** + * Reads a file synchronously. + * @param {string} filePath The path to read + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + * @throws {Error} If path does not exist or is a directory + */ + readFileSync(filePath, options) { + const entry = this._resolveEntry(filePath); + if (!entry) { + throw createENOENT('open', filePath); + } + if (entry.isDirectory()) { + throw createEISDIR('read', filePath); + } + + const content = entry.getContentSync(); + + // Handle encoding + if (options) { + const encoding = typeof options === 'string' ? options : options.encoding; + if (encoding) { + return content.toString(encoding); + } + } + + return content; + } + + /** + * Reads directory contents synchronously. + * @param {string} dirPath The directory path + * @param {object} [options] Options + * @param {boolean} [options.withFileTypes] Return Dirent objects + * @returns {string[]|Dirent[]} + * @throws {Error} If path does not exist or is not a directory + */ + readdirSync(dirPath, options) { + const entry = this._resolveEntry(dirPath); + if (!entry) { + throw createENOENT('scandir', dirPath); + } + if (!entry.isDirectory()) { + throw createENOTDIR('scandir', dirPath); + } + + const names = entry.getEntryNames(); + + if (options?.withFileTypes) { + const dirents = []; + for (const name of names) { + const childEntry = entry.getEntry(name); + 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, dirPath)); + } + return dirents; + } + + return names; + } + + /** + * Gets the real path by resolving all symlinks in the path. + * @param {string} filePath The path + * @returns {string} + * @throws {Error} If path does not exist or symlink loop detected + */ + realpathSync(filePath) { + const result = this._resolveEntryInternal(filePath, true, 0); + if (result.eloop) { + throw createELOOP('realpath', filePath); + } + if (!result.entry) { + throw createENOENT('realpath', filePath); + } + return result.resolvedPath; + } + + /** + * Reads the target of a symbolic link. + * @param {string} linkPath The path to the symlink + * @param {object} [options] Options (encoding) + * @returns {string} The symlink target + * @throws {Error} If path does not exist or is not a symlink + */ + readlinkSync(linkPath, options) { + const entry = this._resolveEntry(linkPath, false); + if (!entry) { + throw createENOENT('readlink', linkPath); + } + if (!entry.isSymbolicLink()) { + // EINVAL is thrown when the path is not a symlink + throw createEINVAL('readlink', linkPath); + } + return entry.target; + } + + /** + * Returns the stat result code for module resolution. + * Used by Module._stat override. + * @param {string} filePath The path to check + * @returns {number} 0 for file, 1 for directory, -2 for not found + */ + internalModuleStat(filePath) { + const entry = this._resolveEntry(filePath); + if (!entry) { + return -2; // ENOENT + } + if (entry.isDirectory()) { + return 1; + } + return 0; + } + + // ==================== 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) { + // Handle optional options argument + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const entry = this._resolveEntry(filePath); + if (!entry) { + process.nextTick(callback, createENOENT('open', filePath)); + return; + } + if (entry.isDirectory()) { + process.nextTick(callback, createEISDIR('read', filePath)); + return; + } + + // Use async getContent for dynamic content support + entry.getContent().then((content) => { + // Handle encoding + if (options) { + const encoding = typeof options === 'string' ? options : options.encoding; + if (encoding) { + callback(null, content.toString(encoding)); + return; + } + } + callback(null, content); + }).catch((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) { + // Handle optional options argument + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const entry = this._resolveEntry(filePath); + if (!entry) { + process.nextTick(callback, createENOENT('stat', filePath)); + return; + } + process.nextTick(callback, null, entry.getStats()); + } + + /** + * Gets stats for a path asynchronously (same as stat for VFS, no symlinks). + * @param {string} filePath The path to stat + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + lstat(filePath, options, callback) { + this.stat(filePath, options, callback); + } + + /** + * 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) { + // Handle optional options argument + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const entry = this._resolveEntry(dirPath); + if (!entry) { + process.nextTick(callback, createENOENT('scandir', dirPath)); + return; + } + if (!entry.isDirectory()) { + process.nextTick(callback, createENOTDIR('scandir', dirPath)); + return; + } + + const names = entry.getEntryNames(); + + if (options?.withFileTypes) { + const dirents = []; + for (const name of names) { + const childEntry = entry.getEntry(name); + 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, dirPath)); + } + process.nextTick(callback, null, dirents); + return; + } + + process.nextTick(callback, null, names); + } + + /** + * Gets the real path by resolving all symlinks asynchronously. + * @param {string} filePath The path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, resolvedPath) + */ + realpath(filePath, options, callback) { + // Handle optional options argument + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const result = this._resolveEntryInternal(filePath, true, 0); + if (result.eloop) { + process.nextTick(callback, createELOOP('realpath', filePath)); + return; + } + if (!result.entry) { + process.nextTick(callback, createENOENT('realpath', filePath)); + return; + } + process.nextTick(callback, null, result.resolvedPath); + } + + /** + * Reads the target of a symbolic link asynchronously. + * @param {string} linkPath The path to the symlink + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, target) + */ + readlink(linkPath, options, callback) { + // Handle optional options argument + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const entry = this._resolveEntry(linkPath, false); + if (!entry) { + process.nextTick(callback, createENOENT('readlink', linkPath)); + return; + } + if (!entry.isSymbolicLink()) { + process.nextTick(callback, createEINVAL('readlink', linkPath)); + return; + } + process.nextTick(callback, null, entry.target); + } + + /** + * 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) { + // Handle optional mode argument + if (typeof mode === 'function') { + callback = mode; + mode = undefined; + } + + const entry = this._resolveEntry(filePath); + if (!entry) { + process.nextTick(callback, createENOENT('access', filePath)); + return; + } + // VFS files are always readable (no permission checks for now) + process.nextTick(callback, null); + } + + // ==================== 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 (ignored for VFS) + * @returns {number} The file descriptor + * @throws {Error} If path does not exist or is a directory + */ + openSync(filePath, flags = 'r', mode) { + const entry = this._resolveEntry(filePath); + if (!entry) { + throw createENOENT('open', filePath); + } + if (entry.isDirectory()) { + throw createEISDIR('open', filePath); + } + return openVirtualFd(entry, flags, normalizePath(filePath)); + } + + /** + * Closes a file descriptor synchronously. + * @param {number} fd The file descriptor + * @throws {Error} If fd is not a valid virtual fd + */ + closeSync(fd) { + if (!isVirtualFd(fd)) { + throw createEBADF('close'); + } + 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 to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position in the file to read from (null uses current position) + * @returns {number} The number of bytes read + * @throws {Error} If fd is not valid + */ + readSync(fd, buffer, offset, length, position) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('read'); + } + + const content = vfd.getContentSync(); + const readPos = position !== null && position !== undefined ? position : vfd.position; + + // Calculate how many bytes we can actually read + 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) { + vfd.position = readPos + bytesToRead; + } + + return bytesToRead; + } + + /** + * Gets file stats from a file descriptor synchronously. + * @param {number} fd The file descriptor + * @returns {Stats} + * @throws {Error} If fd is not valid + */ + fstatSync(fd) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('fstat'); + } + return vfd.entry.getStats(); + } + + /** + * 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) { + // Handle optional arguments + if (typeof flags === 'function') { + callback = flags; + flags = 'r'; + mode = undefined; + } else if (typeof mode === 'function') { + callback = mode; + mode = undefined; + } + + const entry = this._resolveEntry(filePath); + if (!entry) { + process.nextTick(callback, createENOENT('open', filePath)); + return; + } + if (entry.isDirectory()) { + process.nextTick(callback, createEISDIR('open', filePath)); + return; + } + const fd = openVirtualFd(entry, flags, normalizePath(filePath)); + process.nextTick(callback, null, fd); + } + + /** + * Closes a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {Function} callback Callback (err) + */ + close(fd, callback) { + if (!isVirtualFd(fd)) { + process.nextTick(callback, createEBADF('close')); + return; + } + closeVirtualFd(fd); + process.nextTick(callback, null); + } + + /** + * 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.getContent().then((content) => { + const readPos = position !== null && position !== undefined ? position : vfd.position; + + // Calculate how many bytes we can actually read + const available = content.length - readPos; + if (available <= 0) { + callback(null, 0, buffer); + return; + } + + const bytesToRead = MathMin(length, available); + content.copy(buffer, offset, readPos, readPos + bytesToRead); + + // Update position if not using explicit position + if (position === null || position === undefined) { + vfd.position = readPos + bytesToRead; + } + + callback(null, bytesToRead, buffer); + }).catch((err) => { + 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; + } + process.nextTick(callback, null, vfd.entry.getStats()); + } + + // ==================== Stream Operations ==================== + + /** + * Creates a readable stream for a virtual file. + * @param {string} filePath The path to the file + * @param {object} [options] Stream options + * @param {number} [options.start] Start position in file + * @param {number} [options.end] End position in file + * @param {number} [options.highWaterMark] High water mark for the stream + * @param {string} [options.encoding] Encoding for the stream + * @param {boolean} [options.autoClose] Auto-close fd on end/error (default: true) + * @returns {ReadStream} + */ + createReadStream(filePath, options) { + return createVirtualReadStream(this, filePath, options); + } + + // ==================== Promise API ==================== + + /** + * Gets the promises API for this VFS instance. + * @returns {object} Promise-based fs methods + */ + get promises() { + if (this[kPromises] === null) { + this[kPromises] = createPromisesAPI(this); + } + return this[kPromises]; + } +} + +/** + * Creates the promises API object for a VFS instance. + * @param {VirtualFileSystem} vfs The VFS instance + * @returns {object} Promise-based fs methods + */ +function createPromisesAPI(vfs) { + return ObjectFreeze({ + /** + * Reads a file asynchronously. + * @param {string} filePath The path to read + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(filePath, options) { + const entry = vfs._resolveEntry(filePath); + if (!entry) { + throw createENOENT('open', filePath); + } + if (entry.isDirectory()) { + throw createEISDIR('read', filePath); + } + + const content = await entry.getContent(); + + // Handle encoding + if (options) { + const encoding = typeof options === 'string' ? options : options.encoding; + if (encoding) { + return content.toString(encoding); + } + } + + return content; + }, + + /** + * Gets stats for a path asynchronously. + * Follows symlinks to get the target's stats. + * @param {string} filePath The path to stat + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(filePath, options) { + const entry = vfs._resolveEntryOrThrow(filePath, 'stat', true); + return entry.getStats(); + }, + + /** + * Gets stats for a path asynchronously without following symlinks. + * @param {string} filePath The path to stat + * @param {object} [options] Options + * @returns {Promise} + */ + async lstat(filePath, options) { + const entry = vfs._resolveEntryOrThrow(filePath, 'lstat', false); + return entry.getStats(); + }, + + /** + * Reads directory contents asynchronously. + * @param {string} dirPath The directory path + * @param {object} [options] Options + * @returns {Promise} + */ + async readdir(dirPath, options) { + const entry = vfs._resolveEntry(dirPath); + if (!entry) { + throw createENOENT('scandir', dirPath); + } + if (!entry.isDirectory()) { + throw createENOTDIR('scandir', dirPath); + } + + const names = entry.getEntryNames(); + + if (options?.withFileTypes) { + const dirents = []; + for (const name of names) { + const childEntry = entry.getEntry(name); + 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, dirPath)); + } + return dirents; + } + + return names; + }, + + /** + * Gets the real path by resolving all symlinks. + * @param {string} filePath The path + * @param {object} [options] Options + * @returns {Promise} + */ + async realpath(filePath, options) { + const result = vfs._resolveEntryInternal(filePath, true, 0); + if (result.eloop) { + throw createELOOP('realpath', filePath); + } + if (!result.entry) { + throw createENOENT('realpath', filePath); + } + return result.resolvedPath; + }, + + /** + * Reads the target of a symbolic link. + * @param {string} linkPath The path to the symlink + * @param {object} [options] Options + * @returns {Promise} + */ + async readlink(linkPath, options) { + const entry = vfs._resolveEntry(linkPath, false); + if (!entry) { + throw createENOENT('readlink', linkPath); + } + if (!entry.isSymbolicLink()) { + throw createEINVAL('readlink', linkPath); + } + return entry.target; + }, + + /** + * Checks file accessibility asynchronously. + * @param {string} filePath The path to check + * @param {number} [mode] Access mode + * @returns {Promise} + */ + async access(filePath, mode) { + const entry = vfs._resolveEntry(filePath); + if (!entry) { + throw createENOENT('access', filePath); + } + // VFS files are always readable (no permission checks for now) + }, + }); +} + +module.exports = { + VirtualFileSystem, +}; diff --git a/lib/sea.js b/lib/sea.js index 5da9a75d095d7a..12e79f29a32b66 100644 --- a/lib/sea.js +++ b/lib/sea.js @@ -81,10 +81,17 @@ function getAssetKeys() { return getAssetKeysInternal() || []; } +const { + getSeaVfs: getVfs, + hasSeaAssets: hasAssets, +} = require('internal/vfs/sea'); + module.exports = { isSea, getAsset, getRawAsset, getAssetAsBlob, getAssetKeys, + getVfs, + hasAssets, }; diff --git a/lib/vfs.js b/lib/vfs.js new file mode 100644 index 00000000000000..6def631e112d36 --- /dev/null +++ b/lib/vfs.js @@ -0,0 +1,60 @@ +'use strict'; + +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +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'); + +// SEAProvider is lazy-loaded to avoid loading SEA bindings when not needed +let SEAProvider; + +function getSEAProvider() { + if (SEAProvider === undefined) { + try { + SEAProvider = require('internal/vfs/providers/sea').SEAProvider; + } catch { + // SEA bindings not available (not running in SEA) + SEAProvider = class SEAProviderUnavailable { + constructor() { + throw new ERR_INVALID_STATE('SEAProvider can only be used in a Single Executable Application'); + } + }; + } + } + return SEAProvider; +} + +/** + * 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 !== undefined && provider !== null && + !(provider instanceof VirtualProvider) && + typeof provider === 'object') { + options = provider; + provider = undefined; + } + return new VirtualFileSystem(provider, options); +} + +module.exports = { + create, + VirtualFileSystem, + VirtualProvider, + MemoryProvider, + RealFSProvider, + get SEAProvider() { + return getSEAProvider(); + }, +}; diff --git a/test/fixtures/sea/vfs/config.json b/test/fixtures/sea/vfs/config.json new file mode 100644 index 00000000000000..8dd57a1d1fdf7b --- /dev/null +++ b/test/fixtures/sea/vfs/config.json @@ -0,0 +1 @@ +{"name":"test-app","version":"1.0.0"} diff --git a/test/fixtures/sea/vfs/greeting.txt b/test/fixtures/sea/vfs/greeting.txt new file mode 100644 index 00000000000000..cbfbb578572c03 --- /dev/null +++ b/test/fixtures/sea/vfs/greeting.txt @@ -0,0 +1 @@ +Hello from SEA VFS! \ No newline at end of file diff --git a/test/fixtures/sea/vfs/math.js b/test/fixtures/sea/vfs/math.js new file mode 100644 index 00000000000000..26be73d850ac20 --- /dev/null +++ b/test/fixtures/sea/vfs/math.js @@ -0,0 +1,4 @@ +module.exports = { + add: (a, b) => a + b, + multiply: (a, b) => a * b, +}; diff --git a/test/fixtures/sea/vfs/sea-config.json b/test/fixtures/sea/vfs/sea-config.json new file mode 100644 index 00000000000000..aac7de75075a64 --- /dev/null +++ b/test/fixtures/sea/vfs/sea-config.json @@ -0,0 +1,9 @@ +{ + "main": "sea.js", + "output": "sea-prep.blob", + "assets": { + "config.json": "config.json", + "data/greeting.txt": "greeting.txt", + "modules/math.js": "math.js" + } +} diff --git a/test/fixtures/sea/vfs/sea.js b/test/fixtures/sea/vfs/sea.js new file mode 100644 index 00000000000000..964c94aaadfe69 --- /dev/null +++ b/test/fixtures/sea/vfs/sea.js @@ -0,0 +1,63 @@ +'use strict'; +const fs = require('fs'); +const sea = require('node:sea'); +const assert = require('assert'); + +// Test hasSeaAssets() returns true when we have assets +const hasAssets = sea.hasAssets(); +assert.strictEqual(hasAssets, true, 'hasSeaAssets() should return true'); +console.log('hasSeaAssets:', hasAssets); + +// Test getSeaVfs() returns a VFS instance +const vfs = sea.getVfs(); +assert.ok(vfs !== null, 'getSeaVfs() should not return null'); +console.log('getSeaVfs returned VFS instance'); + +// Test that the VFS is mounted at /sea by default +// and contains our assets + +// Read the config file through standard fs (via VFS hooks) +const configContent = fs.readFileSync('/sea/config.json', 'utf8'); +const config = JSON.parse(configContent); +assert.strictEqual(config.name, 'test-app', 'config.name should match'); +assert.strictEqual(config.version, '1.0.0', 'config.version should match'); +console.log('Read config.json:', config); + +// Read a text file +const greeting = fs.readFileSync('/sea/data/greeting.txt', 'utf8'); +assert.strictEqual(greeting, 'Hello from SEA VFS!', 'greeting should match'); +console.log('Read greeting.txt:', greeting); + +// Test existsSync +assert.strictEqual(fs.existsSync('/sea/config.json'), true); +assert.strictEqual(fs.existsSync('/sea/data/greeting.txt'), true); +assert.strictEqual(fs.existsSync('/sea/nonexistent.txt'), false); +console.log('existsSync tests passed'); + +// Test statSync +const configStat = fs.statSync('/sea/config.json'); +assert.strictEqual(configStat.isFile(), true); +assert.strictEqual(configStat.isDirectory(), false); +console.log('statSync tests passed'); + +// Test readdirSync +const entries = fs.readdirSync('/sea'); +assert.ok(entries.includes('config.json'), 'Should include config.json'); +assert.ok(entries.includes('data'), 'Should include data directory'); +console.log('readdirSync tests passed, entries:', entries); + +// Test requiring a module from SEA VFS using direct require() +// (SEA's require now supports VFS paths automatically) +const mathModule = require('/sea/modules/math.js'); +assert.strictEqual(mathModule.add(2, 3), 5, 'math.add should work'); +assert.strictEqual(mathModule.multiply(4, 5), 20, 'math.multiply should work'); +console.log('direct require from VFS tests passed'); + +// Test getSeaVfs with custom prefix +const customVfs = sea.getVfs({ prefix: '/custom' }); +// Note: getSeaVfs is a singleton, so it returns the same instance +// with the same mount point (/sea) regardless of options passed after first call +assert.strictEqual(customVfs, vfs, 'Should return the same cached instance'); +console.log('Cached VFS instance test passed'); + +console.log('All SEA VFS tests passed!'); diff --git a/test/parallel/test-permission-fs-supported.js b/test/parallel/test-permission-fs-supported.js index 0c0da9500f2599..fd93d235659835 100644 --- a/test/parallel/test-permission-fs-supported.js +++ b/test/parallel/test-permission-fs-supported.js @@ -61,6 +61,8 @@ const supportedApis = [ // Non functions const ignoreList = [ 'constants', + // VFS operates in-memory only, does not touch real filesystem + 'createVirtual', 'promises', 'X_OK', 'W_OK', diff --git a/test/parallel/test-runner-mock-fs.js b/test/parallel/test-runner-mock-fs.js new file mode 100644 index 00000000000000..408cd6bd8518e3 --- /dev/null +++ b/test/parallel/test-runner-mock-fs.js @@ -0,0 +1,236 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const { test } = require('node:test'); + +// Test basic mock.fs() functionality +test('mock.fs() creates a virtual file system', (t) => { + const mockFs = t.mock.fs({ + prefix: '/test-vfs', + files: { + '/config.json': '{"key": "value"}', + '/data.txt': 'hello world', + }, + }); + + // Verify the mock was created with correct prefix + assert.strictEqual(mockFs.prefix, '/test-vfs'); + + // Verify files are accessible via standard fs APIs + const config = fs.readFileSync('/test-vfs/config.json', 'utf8'); + assert.strictEqual(config, '{"key": "value"}'); + + const data = fs.readFileSync('/test-vfs/data.txt', 'utf8'); + assert.strictEqual(data, 'hello world'); + + // Verify existsSync works + assert.strictEqual(fs.existsSync('/test-vfs/config.json'), true); + assert.strictEqual(fs.existsSync('/test-vfs/nonexistent.txt'), false); +}); + +// Test mock.fs() with default prefix +test('mock.fs() uses /mock as default prefix', (t) => { + const mockFs = t.mock.fs({ + files: { + '/file.txt': 'content', + }, + }); + + assert.strictEqual(mockFs.prefix, '/mock'); + assert.strictEqual(fs.readFileSync('/mock/file.txt', 'utf8'), 'content'); +}); + +// Test mock.fs() without initial files +test('mock.fs() works without initial files', (t) => { + const mockFs = t.mock.fs({ prefix: '/empty-vfs' }); + + // Add files dynamically + mockFs.addFile('/file1.txt', 'content1'); + mockFs.addFile('/file2.txt', 'content2'); + + assert.strictEqual(fs.readFileSync('/empty-vfs/file1.txt', 'utf8'), 'content1'); + assert.strictEqual(fs.readFileSync('/empty-vfs/file2.txt', 'utf8'), 'content2'); +}); + +// Test mock.fs() addDirectory +test('mock.fs() supports adding directories', (t) => { + const mockFs = t.mock.fs({ prefix: '/dir-test' }); + + mockFs.addDirectory('/src'); + mockFs.addFile('/src/index.js', 'module.exports = "hello"'); + + const content = fs.readFileSync('/dir-test/src/index.js', 'utf8'); + assert.strictEqual(content, 'module.exports = "hello"'); + + const entries = fs.readdirSync('/dir-test/src'); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0], 'index.js'); +}); + +// Test mock.fs() existsSync +test('mock.fs() existsSync works correctly', (t) => { + const mockFs = t.mock.fs({ + prefix: '/exists-test', + files: { + '/existing.txt': 'content', + }, + }); + + // Test via mockFs context + assert.strictEqual(mockFs.existsSync('/existing.txt'), true); + assert.strictEqual(mockFs.existsSync('/nonexistent.txt'), false); + + // Test via standard fs + assert.strictEqual(fs.existsSync('/exists-test/existing.txt'), true); + assert.strictEqual(fs.existsSync('/exists-test/nonexistent.txt'), false); +}); + +// Test mock.fs() with Buffer content +test('mock.fs() supports Buffer content', (t) => { + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03]); + t.mock.fs({ + prefix: '/buffer-test', + files: { + '/binary.bin': binaryData, + }, + }); + + const content = fs.readFileSync('/buffer-test/binary.bin'); + assert.deepStrictEqual(content, binaryData); +}); + +// Test mock.fs() automatic cleanup +test('mock.fs() is automatically cleaned up after test', async (t) => { + // Create a mock within a subtest + await t.test('subtest with mock', (st) => { + st.mock.fs({ + prefix: '/cleanup-test', + files: { + '/temp.txt': 'temporary', + }, + }); + assert.strictEqual(fs.existsSync('/cleanup-test/temp.txt'), true); + }); + + // After subtest, the mock should be cleaned up + assert.strictEqual(fs.existsSync('/cleanup-test/temp.txt'), false); +}); + +// Test mock.fs() manual restore +test('mock.fs() can be manually restored', (t) => { + const mockFs = t.mock.fs({ + prefix: '/manual-restore', + files: { + '/file.txt': 'content', + }, + }); + + assert.strictEqual(fs.existsSync('/manual-restore/file.txt'), true); + + // Manually restore + mockFs.restore(); + + // File should no longer be accessible + assert.strictEqual(fs.existsSync('/manual-restore/file.txt'), false); +}); + +// Test mock.fs() vfs property +test('mock.fs() exposes vfs property', (t) => { + const mockFs = t.mock.fs({ + prefix: '/vfs-prop-test', + files: { + '/file.txt': 'content', + }, + }); + + // Access the underlying VFS + const vfs = mockFs.vfs; + assert.ok(vfs); + assert.strictEqual(typeof vfs.addFile, 'function'); + assert.strictEqual(typeof vfs.readFileSync, 'function'); + + // Use VFS directly (requires full path including mount point) + const content = vfs.readFileSync('/vfs-prop-test/file.txt', 'utf8'); + assert.strictEqual(content, 'content'); +}); + +// Test mock.fs() with dynamic file content +test('mock.fs() supports dynamic file content', (t) => { + let counter = 0; + const mockFs = t.mock.fs({ prefix: '/dynamic-test' }); + + mockFs.addFile('/counter.txt', () => { + counter++; + return String(counter); + }); + + // Each read should call the function + assert.strictEqual(fs.readFileSync('/dynamic-test/counter.txt', 'utf8'), '1'); + assert.strictEqual(fs.readFileSync('/dynamic-test/counter.txt', 'utf8'), '2'); + assert.strictEqual(fs.readFileSync('/dynamic-test/counter.txt', 'utf8'), '3'); +}); + +// Test require from mock.fs() +test('mock.fs() supports require() from virtual files', (t) => { + t.mock.fs({ + prefix: '/require-test', + files: { + '/module.js': 'module.exports = { value: 42 };', + }, + }); + + const mod = require('/require-test/module.js'); + assert.strictEqual(mod.value, 42); +}); + +// Test stat from mock.fs() +test('mock.fs() supports statSync', (t) => { + const mockFs = t.mock.fs({ + prefix: '/stat-test', + files: { + '/file.txt': 'hello', + }, + }); + + mockFs.addDirectory('/dir'); + + const fileStat = fs.statSync('/stat-test/file.txt'); + assert.strictEqual(fileStat.isFile(), true); + assert.strictEqual(fileStat.isDirectory(), false); + assert.strictEqual(fileStat.size, 5); + + const dirStat = fs.statSync('/stat-test/dir'); + assert.strictEqual(dirStat.isFile(), false); + assert.strictEqual(dirStat.isDirectory(), true); +}); + +// Test multiple mock.fs() instances +test('multiple mock.fs() instances can coexist', (t) => { + t.mock.fs({ + prefix: '/mock1', + files: { '/file.txt': 'from mock1' }, + }); + + t.mock.fs({ + prefix: '/mock2', + files: { '/file.txt': 'from mock2' }, + }); + + assert.strictEqual(fs.readFileSync('/mock1/file.txt', 'utf8'), 'from mock1'); + assert.strictEqual(fs.readFileSync('/mock2/file.txt', 'utf8'), 'from mock2'); +}); + +// Test that options validation works +test('mock.fs() validates options', (t) => { + assert.throws( + () => t.mock.fs('invalid'), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + + assert.throws( + () => t.mock.fs({ files: 'invalid' }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); +}); diff --git a/test/parallel/test-vfs-basic.js b/test/parallel/test-vfs-basic.js new file mode 100644 index 00000000000000..1b667cee623d6e --- /dev/null +++ b/test/parallel/test-vfs-basic.js @@ -0,0 +1,159 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); + +// Test that VirtualFileSystem can be created via fs.createVirtual() +{ + const myVfs = fs.createVirtual(); + assert.ok(myVfs); + assert.strictEqual(typeof myVfs.writeFileSync, 'function'); + assert.strictEqual(myVfs.mounted, false); +} + +// Test adding and reading a static file +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/test', { recursive: true }); + myVfs.writeFileSync('/test/file.txt', 'hello world'); + + assert.strictEqual(myVfs.existsSync('/test/file.txt'), true); + assert.strictEqual(myVfs.existsSync('/test'), true); + assert.strictEqual(myVfs.existsSync('/nonexistent'), false); + + const content = myVfs.readFileSync('/test/file.txt'); + assert.ok(Buffer.isBuffer(content)); + assert.strictEqual(content.toString(), 'hello world'); + + // Read with encoding + const textContent = myVfs.readFileSync('/test/file.txt', 'utf8'); + assert.strictEqual(textContent, 'hello world'); +} + +// Test statSync +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/test/dir', { recursive: true }); + myVfs.writeFileSync('/test/file.txt', 'content'); + + const fileStat = myVfs.statSync('/test/file.txt'); + assert.strictEqual(fileStat.isFile(), true); + assert.strictEqual(fileStat.isDirectory(), false); + assert.strictEqual(fileStat.size, 7); // 'content'.length + + const dirStat = myVfs.statSync('/test/dir'); + assert.strictEqual(dirStat.isFile(), false); + assert.strictEqual(dirStat.isDirectory(), true); + + // Test ENOENT + assert.throws(() => { + myVfs.statSync('/nonexistent'); + }, { code: 'ENOENT' }); +} + +// Test readdirSync +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/dir/subdir', { recursive: true }); + myVfs.writeFileSync('/dir/a.txt', 'a'); + myVfs.writeFileSync('/dir/b.txt', 'b'); + + const entries = myVfs.readdirSync('/dir'); + assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt', 'subdir']); + + // Test with file types + const dirents = myVfs.readdirSync('/dir', { withFileTypes: true }); + assert.strictEqual(dirents.length, 3); + + const fileEntry = dirents.find((d) => d.name === 'a.txt'); + assert.ok(fileEntry); + assert.strictEqual(fileEntry.isFile(), true); + assert.strictEqual(fileEntry.isDirectory(), false); + + const dirEntry = dirents.find((d) => d.name === 'subdir'); + assert.ok(dirEntry); + assert.strictEqual(dirEntry.isFile(), false); + assert.strictEqual(dirEntry.isDirectory(), true); + + // Test ENOTDIR + assert.throws(() => { + myVfs.readdirSync('/dir/a.txt'); + }, { code: 'ENOTDIR' }); + + // Test ENOENT + assert.throws(() => { + myVfs.readdirSync('/nonexistent'); + }, { code: 'ENOENT' }); +} + +// Test removing entries +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/test', { recursive: true }); + myVfs.writeFileSync('/test/file.txt', 'content'); + + assert.strictEqual(myVfs.existsSync('/test/file.txt'), true); + myVfs.unlinkSync('/test/file.txt'); + assert.strictEqual(myVfs.existsSync('/test/file.txt'), false); + + // Unlinking non-existent entry throws ENOENT + assert.throws(() => { + myVfs.unlinkSync('/nonexistent'); + }, { code: 'ENOENT' }); +} + +// Test mount mode +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/data', { recursive: true }); + myVfs.writeFileSync('/data/file.txt', 'mounted content'); + + assert.strictEqual(myVfs.mounted, false); + myVfs.mount('/app/virtual'); + assert.strictEqual(myVfs.mounted, true); + assert.strictEqual(myVfs.mountPoint, '/app/virtual'); + + // With mount, shouldHandle should work + assert.strictEqual(myVfs.shouldHandle('/app/virtual/data/file.txt'), true); + assert.strictEqual(myVfs.shouldHandle('/other/path'), false); + + myVfs.unmount(); + assert.strictEqual(myVfs.mounted, false); +} + +// Test internalModuleStat (used by Module._stat) +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/dir', { recursive: true }); + myVfs.writeFileSync('/module.js', 'module.exports = {}'); + + assert.strictEqual(myVfs.internalModuleStat('/module.js'), 0); // file + assert.strictEqual(myVfs.internalModuleStat('/dir'), 1); // directory + assert.strictEqual(myVfs.internalModuleStat('/nonexistent'), -2); // ENOENT +} + +// Test reading directory as file throws EISDIR +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/mydir', { recursive: true }); + + assert.throws(() => { + myVfs.readFileSync('/mydir'); + }, { code: 'EISDIR' }); +} + +// Test realpathSync +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/test', { recursive: true }); + myVfs.writeFileSync('/test/file.txt', 'content'); + + const realpath = myVfs.realpathSync('/test/file.txt'); + assert.strictEqual(realpath, '/test/file.txt'); + + // Non-existent path throws + assert.throws(() => { + myVfs.realpathSync('/nonexistent'); + }, { code: 'ENOENT' }); +} diff --git a/test/parallel/test-vfs-chdir-worker.js b/test/parallel/test-vfs-chdir-worker.js new file mode 100644 index 00000000000000..e8d0c880984ca5 --- /dev/null +++ b/test/parallel/test-vfs-chdir-worker.js @@ -0,0 +1,104 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const { Worker, isMainThread, parentPort, workerData } = require('worker_threads'); + +if (isMainThread) { + // Test 1: Verify that VFS setup in main thread doesn't automatically apply to workers + { + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/project', { recursive: true }); + vfs.mount('/virtual'); + + // Set virtual cwd in main thread + process.chdir('/virtual/project'); + assert.strictEqual(process.cwd(), '/virtual/project'); + + // Worker should not have the hooked process.chdir/cwd + const worker = new Worker(__filename, { + workerData: { test: 'main-thread-vfs-not-shared' }, + }); + + worker.on('message', common.mustCall((msg) => { + // Worker's process.cwd() should return the real cwd (not /virtual/project) + // because VFS hooks are not automatically shared with workers + assert.notStrictEqual(msg.cwd, '/virtual/project'); + })); + + worker.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 0); + vfs.unmount(); + })); + } + + // Test 2: VFS can be set up independently in a worker + { + const worker = new Worker(__filename, { + workerData: { test: 'worker-independent-vfs' }, + }); + + worker.on('message', common.mustCall((msg) => { + assert.strictEqual(msg.success, true); + assert.strictEqual(msg.cwd, '/worker-virtual/data'); + })); + + worker.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 0); + })); + } + + // Test 3: Worker can use VFS passed via workerData (not the hooks, but the VFS module) + { + const worker = new Worker(__filename, { + workerData: { test: 'worker-create-vfs' }, + }); + + worker.on('message', common.mustCall((msg) => { + assert.strictEqual(msg.success, true); + assert.strictEqual(msg.virtualCwdEnabled, true); + assert.strictEqual(msg.vfsCwd, '/project/src'); + })); + + worker.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 0); + })); + } + +} else { + // Worker thread code + const { test } = workerData; + + if (test === 'main-thread-vfs-not-shared') { + // Simply report our cwd + parentPort.postMessage({ cwd: process.cwd() }); + } else if (test === 'worker-independent-vfs') { + // Set up VFS independently in worker + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/data', { recursive: true }); + vfs.mount('/worker-virtual'); + + process.chdir('/worker-virtual/data'); + const cwd = process.cwd(); + + vfs.unmount(); + + parentPort.postMessage({ success: true, cwd }); + } else if (test === 'worker-create-vfs') { + // Test VFS creation and chdir in worker + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/project/src', { recursive: true }); + vfs.mount('/'); + + vfs.chdir('/project/src'); + + parentPort.postMessage({ + success: true, + virtualCwdEnabled: vfs.virtualCwdEnabled, + vfsCwd: vfs.cwd(), + }); + + vfs.unmount(); + } +} diff --git a/test/parallel/test-vfs-chdir.js b/test/parallel/test-vfs-chdir.js new file mode 100644 index 00000000000000..fc5ea4c11b7d32 --- /dev/null +++ b/test/parallel/test-vfs-chdir.js @@ -0,0 +1,217 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); + +// Test that virtualCwd option is disabled by default +{ + const vfs = fs.createVirtual(); + assert.strictEqual(vfs.virtualCwdEnabled, false); + + // Should throw when trying to use cwd() without enabling + assert.throws(() => { + vfs.cwd(); + }, { code: 'ERR_INVALID_STATE' }); + + // Should throw when trying to use chdir() without enabling + assert.throws(() => { + vfs.chdir('/'); + }, { code: 'ERR_INVALID_STATE' }); +} + +// Test that virtualCwd option can be enabled +{ + const vfs = fs.createVirtual({ virtualCwd: true }); + assert.strictEqual(vfs.virtualCwdEnabled, true); + + // Initial cwd should be null + assert.strictEqual(vfs.cwd(), null); +} + +// Test basic chdir functionality +{ + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/project/src', { recursive: true }); + vfs.writeFileSync('/project/src/index.js', 'module.exports = "hello";'); + vfs.mount('/virtual'); + + // Change to a directory that exists + vfs.chdir('/virtual/project'); + assert.strictEqual(vfs.cwd(), '/virtual/project'); + + // Change to a subdirectory + vfs.chdir('/virtual/project/src'); + assert.strictEqual(vfs.cwd(), '/virtual/project/src'); + + vfs.unmount(); +} + +// Test chdir with non-existent path throws ENOENT +{ + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/project', { recursive: true }); + vfs.mount('/virtual'); + + assert.throws(() => { + vfs.chdir('/virtual/nonexistent'); + }, { code: 'ENOENT' }); + + vfs.unmount(); +} + +// Test chdir with file path throws ENOTDIR +{ + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.writeFileSync('/file.txt', 'content'); + vfs.mount('/virtual'); + + assert.throws(() => { + vfs.chdir('/virtual/file.txt'); + }, { code: 'ENOTDIR' }); + + vfs.unmount(); +} + +// Test resolvePath with virtual cwd +{ + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/project/src', { recursive: true }); + vfs.writeFileSync('/project/src/index.js', 'module.exports = "hello";'); + vfs.mount('/virtual'); + + // Before setting cwd, relative paths use real cwd + const resolvedBefore = vfs.resolvePath('test.js'); + assert.ok(resolvedBefore.endsWith('test.js')); + + // Set virtual cwd + vfs.chdir('/virtual/project'); + + // Absolute paths are returned as-is + assert.strictEqual(vfs.resolvePath('/absolute/path'), '/absolute/path'); + + // Relative paths are resolved relative to virtual cwd + assert.strictEqual(vfs.resolvePath('src/index.js'), '/virtual/project/src/index.js'); + assert.strictEqual(vfs.resolvePath('./src/index.js'), '/virtual/project/src/index.js'); + + // Change to subdirectory and resolve again + vfs.chdir('/virtual/project/src'); + assert.strictEqual(vfs.resolvePath('index.js'), '/virtual/project/src/index.js'); + + vfs.unmount(); +} + +// Test resolvePath without virtual cwd enabled +{ + const vfs = fs.createVirtual({ virtualCwd: false }); + vfs.mount('/virtual'); + + // Should still work, but uses real cwd for relative paths + const resolved = vfs.resolvePath('/absolute/path'); + assert.strictEqual(resolved, '/absolute/path'); + + vfs.unmount(); +} + +// Test process.chdir() interception +{ + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/project/src', { recursive: true }); + vfs.mount('/virtual'); + + const originalCwd = process.cwd(); + + // process.chdir to VFS path + process.chdir('/virtual/project'); + assert.strictEqual(process.cwd(), '/virtual/project'); + assert.strictEqual(vfs.cwd(), '/virtual/project'); + + // process.chdir to another VFS path + process.chdir('/virtual/project/src'); + assert.strictEqual(process.cwd(), '/virtual/project/src'); + assert.strictEqual(vfs.cwd(), '/virtual/project/src'); + + vfs.unmount(); + + // After unmount, process.cwd should return original cwd + assert.strictEqual(process.cwd(), originalCwd); +} + +// Test process.chdir() to real path falls through +{ + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/project', { recursive: true }); + vfs.mount('/virtual'); + + const originalCwd = process.cwd(); + + // Change to a real directory (not under /virtual) + // Use realpathSync because /tmp may be a symlink (e.g., /tmp -> /private/tmp on macOS) + const tmpDir = fs.realpathSync('/tmp'); + process.chdir('/tmp'); + assert.strictEqual(process.cwd(), tmpDir); + // vfs.cwd() should still be null (not set) + assert.strictEqual(vfs.cwd(), null); + + // Change back to original + process.chdir(originalCwd); + + vfs.unmount(); +} + +// Test process.cwd() returns virtual cwd when set +{ + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/project', { recursive: true }); + vfs.mount('/virtual'); + + const originalCwd = process.cwd(); + + // Before chdir, process.cwd returns real cwd + assert.strictEqual(process.cwd(), originalCwd); + + // Set virtual cwd + vfs.chdir('/virtual/project'); + assert.strictEqual(process.cwd(), '/virtual/project'); + + vfs.unmount(); + + // After unmount, returns real cwd + assert.strictEqual(process.cwd(), originalCwd); +} + +// Test that process.chdir/cwd are not hooked when virtualCwd is disabled +{ + const originalChdir = process.chdir; + const originalCwd = process.cwd; + + const vfs = fs.createVirtual({ virtualCwd: false }); + vfs.mkdirSync('/project', { recursive: true }); + vfs.mount('/virtual'); + + // process.chdir and process.cwd should not be modified + assert.strictEqual(process.chdir, originalChdir); + assert.strictEqual(process.cwd, originalCwd); + + vfs.unmount(); +} + +// Test virtual cwd is reset on unmount +{ + const vfs = fs.createVirtual({ virtualCwd: true }); + vfs.mkdirSync('/project', { recursive: true }); + vfs.mount('/virtual'); + + vfs.chdir('/virtual/project'); + assert.strictEqual(vfs.cwd(), '/virtual/project'); + + vfs.unmount(); + + // After unmount, cwd should throw (not enabled) + // Actually, virtualCwdEnabled is still true, just unmounted + // Let's remount and check cwd is reset + vfs.mount('/virtual'); + assert.strictEqual(vfs.cwd(), null); + + vfs.unmount(); +} diff --git a/test/parallel/test-vfs-fd.js b/test/parallel/test-vfs-fd.js new file mode 100644 index 00000000000000..9e971a16a340a3 --- /dev/null +++ b/test/parallel/test-vfs-fd.js @@ -0,0 +1,318 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); + +// Test openSync and closeSync +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/file.txt', 'hello world'); + + const fd = myVfs.openSync('/file.txt'); + assert.ok(fd >= 10000, 'VFS fd should be >= 10000'); + myVfs.closeSync(fd); +} + +// Test openSync with non-existent file +{ + const myVfs = fs.createVirtual(); + + assert.throws(() => { + myVfs.openSync('/nonexistent.txt'); + }, { code: 'ENOENT' }); +} + +// Test openSync with directory +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/mydir', { recursive: true }); + + assert.throws(() => { + myVfs.openSync('/mydir'); + }, { code: 'EISDIR' }); +} + +// Test closeSync with invalid fd +{ + const myVfs = fs.createVirtual(); + + assert.throws(() => { + myVfs.closeSync(12345); + }, { code: 'EBADF' }); +} + +// Test readSync +{ + const myVfs = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + const buffer = Buffer.alloc(10); + + assert.throws(() => { + myVfs.readSync(99999, buffer, 0, 10, 0); + }, { code: 'EBADF' }); +} + +// Test fstatSync +{ + const myVfs = fs.createVirtual(); + 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 = fs.createVirtual(); + + assert.throws(() => { + myVfs.fstatSync(99999); + }, { code: 'EBADF' }); +} + +// Test async open and close +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/async-file.txt', 'async content'); + + myVfs.open('/async-file.txt', common.mustCall((err, fd) => { + assert.strictEqual(err, null); + assert.ok(fd >= 10000); + + myVfs.close(fd, common.mustCall((err) => { + assert.strictEqual(err, null); + })); + })); +} + +// Test async open with error +{ + const myVfs = fs.createVirtual(); + + 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 = fs.createVirtual(); + + myVfs.close(99999, common.mustCall((err) => { + assert.strictEqual(err.code, 'EBADF'); + })); +} + +// Test async read +{ + const myVfs = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + + myVfs.fstat(99999, common.mustCall((err, stats) => { + assert.strictEqual(err.code, 'EBADF'); + })); +} + +// Test that separate VFS instances have separate fd spaces +{ + const vfs1 = fs.createVirtual(); + const vfs2 = fs.createVirtual(); + + 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 >= 10000); + assert.ok(fd2 >= 10000); + + // 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 = fs.createVirtual(); + 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-glob.js b/test/parallel/test-vfs-glob.js new file mode 100644 index 00000000000000..a3a13440284674 --- /dev/null +++ b/test/parallel/test-vfs-glob.js @@ -0,0 +1,183 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); + +// Test globSync with VFS mounted directory +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/src/lib/deep', { recursive: true }); + myVfs.mkdirSync('/src/empty', { recursive: true }); + myVfs.writeFileSync('/src/index.js', 'export default 1;'); + myVfs.writeFileSync('/src/utils.js', 'export const util = 1;'); + myVfs.writeFileSync('/src/lib/helper.js', 'export const helper = 1;'); + myVfs.writeFileSync('/src/lib/deep/nested.js', 'export const nested = 1;'); + myVfs.mount('/virtual'); + + // Test simple glob pattern + const jsFiles = fs.globSync('/virtual/src/*.js'); + assert.strictEqual(jsFiles.length, 2); + assert.ok(jsFiles.includes('/virtual/src/index.js')); + assert.ok(jsFiles.includes('/virtual/src/utils.js')); + + // Test recursive glob pattern + const allJsFiles = fs.globSync('/virtual/src/**/*.js'); + assert.strictEqual(allJsFiles.length, 4); + assert.ok(allJsFiles.includes('/virtual/src/index.js')); + assert.ok(allJsFiles.includes('/virtual/src/utils.js')); + assert.ok(allJsFiles.includes('/virtual/src/lib/helper.js')); + assert.ok(allJsFiles.includes('/virtual/src/lib/deep/nested.js')); + + // Test glob with directory matching + const dirs = fs.globSync('/virtual/src/*/', { withFileTypes: false }); + // Glob returns paths ending with / for directories + assert.ok(dirs.some((d) => d.includes('lib'))); + assert.ok(dirs.some((d) => d.includes('empty'))); + + myVfs.unmount(); +} + +// Test async glob (callback API) with VFS mounted directory +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/async-src/lib', { recursive: true }); + myVfs.writeFileSync('/async-src/index.js', 'export default 1;'); + myVfs.writeFileSync('/async-src/utils.js', 'export const util = 1;'); + myVfs.writeFileSync('/async-src/lib/helper.js', 'export const helper = 1;'); + myVfs.mount('/async-virtual'); + + fs.glob('/async-virtual/async-src/*.js', common.mustCall((err, files) => { + assert.strictEqual(err, null); + assert.strictEqual(files.length, 2); + assert.ok(files.includes('/async-virtual/async-src/index.js')); + assert.ok(files.includes('/async-virtual/async-src/utils.js')); + + // Test recursive pattern with callback + fs.glob('/async-virtual/async-src/**/*.js', common.mustCall((err, allFiles) => { + assert.strictEqual(err, null); + assert.strictEqual(allFiles.length, 3); + assert.ok(allFiles.includes('/async-virtual/async-src/index.js')); + assert.ok(allFiles.includes('/async-virtual/async-src/utils.js')); + assert.ok(allFiles.includes('/async-virtual/async-src/lib/helper.js')); + + myVfs.unmount(); + })); + })); +} + +// Test async glob (promise API) with VFS +(async () => { + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/promise-src', { recursive: true }); + myVfs.writeFileSync('/promise-src/a.ts', 'const a = 1;'); + myVfs.writeFileSync('/promise-src/b.ts', 'const b = 2;'); + myVfs.writeFileSync('/promise-src/c.js', 'const c = 3;'); + myVfs.mount('/promise-virtual'); + + const { glob } = require('node:fs/promises'); + + // Glob returns an async iterator, need to collect results + const tsFiles = []; + for await (const file of glob('/promise-virtual/promise-src/*.ts')) { + tsFiles.push(file); + } + assert.strictEqual(tsFiles.length, 2); + assert.ok(tsFiles.includes('/promise-virtual/promise-src/a.ts')); + assert.ok(tsFiles.includes('/promise-virtual/promise-src/b.ts')); + + // Test multiple patterns + const allFiles = []; + for await (const file of glob(['/promise-virtual/promise-src/*.ts', '/promise-virtual/promise-src/*.js'])) { + allFiles.push(file); + } + assert.strictEqual(allFiles.length, 3); + + myVfs.unmount(); +})().then(common.mustCall()); + +// Test glob with withFileTypes option +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/typed/subdir', { recursive: true }); + myVfs.writeFileSync('/typed/file.txt', 'text'); + myVfs.writeFileSync('/typed/subdir/nested.txt', 'nested'); + myVfs.mount('/typedvfs'); + + const entries = fs.globSync('/typedvfs/typed/*', { withFileTypes: true }); + assert.strictEqual(entries.length, 2); + + const fileEntry = entries.find((e) => e.name === 'file.txt'); + assert.ok(fileEntry); + assert.strictEqual(fileEntry.isFile(), true); + assert.strictEqual(fileEntry.isDirectory(), false); + + const dirEntry = entries.find((e) => e.name === 'subdir'); + assert.ok(dirEntry); + assert.strictEqual(dirEntry.isFile(), false); + assert.strictEqual(dirEntry.isDirectory(), true); + + myVfs.unmount(); +} + +// Test glob with multiple patterns +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/multi', { recursive: true }); + myVfs.writeFileSync('/multi/a.js', 'a'); + myVfs.writeFileSync('/multi/b.ts', 'b'); + myVfs.writeFileSync('/multi/c.md', 'c'); + myVfs.mount('/multipat'); + + const files = fs.globSync(['/multipat/multi/*.js', '/multipat/multi/*.ts']); + assert.strictEqual(files.length, 2); + assert.ok(files.includes('/multipat/multi/a.js')); + assert.ok(files.includes('/multipat/multi/b.ts')); + + myVfs.unmount(); +} + +// Test that unmounting stops glob from finding VFS files +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/unmount-test', { recursive: true }); + myVfs.writeFileSync('/unmount-test/file.js', 'content'); + myVfs.mount('/unmount-glob'); + + let files = fs.globSync('/unmount-glob/unmount-test/*.js'); + assert.strictEqual(files.length, 1); + + myVfs.unmount(); + + files = fs.globSync('/unmount-glob/unmount-test/*.js'); + assert.strictEqual(files.length, 0); +} + +// Test glob pattern that doesn't match anything +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/nomatch', { recursive: true }); + myVfs.writeFileSync('/nomatch/file.txt', 'content'); + myVfs.mount('/nomatchvfs'); + + const files = fs.globSync('/nomatchvfs/nomatch/*.nonexistent'); + assert.strictEqual(files.length, 0); + + myVfs.unmount(); +} + +// Test cwd option with VFS (relative patterns) +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/cwd-test', { recursive: true }); + myVfs.writeFileSync('/cwd-test/a.js', 'a'); + myVfs.writeFileSync('/cwd-test/b.js', 'b'); + myVfs.mount('/cwdvfs'); + + const files = fs.globSync('*.js', { cwd: '/cwdvfs/cwd-test' }); + assert.strictEqual(files.length, 2); + assert.ok(files.includes('a.js')); + assert.ok(files.includes('b.js')); + + myVfs.unmount(); +} diff --git a/test/parallel/test-vfs-import.mjs b/test/parallel/test-vfs-import.mjs new file mode 100644 index 00000000000000..cb254c96724e17 --- /dev/null +++ b/test/parallel/test-vfs-import.mjs @@ -0,0 +1,106 @@ +import '../common/index.mjs'; +import assert from 'assert'; +import fs from 'fs'; + +// Test importing a simple virtual ES module +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/hello.mjs', 'export const message = "hello from vfs";'); + myVfs.mount('/virtual'); + + const { message } = await import('/virtual/hello.mjs'); + assert.strictEqual(message, 'hello from vfs'); + + myVfs.unmount(); +} + +// Test importing a virtual module with default export +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/default.mjs', 'export default { name: "test", value: 42 };'); + myVfs.mount('/virtual2'); + + const mod = await import('/virtual2/default.mjs'); + assert.strictEqual(mod.default.name, 'test'); + assert.strictEqual(mod.default.value, 42); + + myVfs.unmount(); +} + +// Test importing a virtual module that imports another virtual module +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/utils.mjs', 'export function add(a, b) { return a + b; }'); + myVfs.writeFileSync('/main.mjs', ` + import { add } from '/virtual3/utils.mjs'; + export const result = add(10, 20); + `); + myVfs.mount('/virtual3'); + + const { result } = await import('/virtual3/main.mjs'); + assert.strictEqual(result, 30); + + myVfs.unmount(); +} + +// Test importing with relative paths +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/lib', { recursive: true }); + myVfs.writeFileSync('/lib/helper.mjs', 'export const helper = () => "helped";'); + myVfs.writeFileSync('/lib/index.mjs', ` + import { helper } from './helper.mjs'; + export const output = helper(); + `); + myVfs.mount('/virtual4'); + + const { output } = await import('/virtual4/lib/index.mjs'); + assert.strictEqual(output, 'helped'); + + myVfs.unmount(); +} + +// Test importing JSON from VFS (with import assertion) +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/data.json', JSON.stringify({ items: [1, 2, 3], enabled: true })); + myVfs.mount('/virtual5'); + + const data = await import('/virtual5/data.json', { with: { type: 'json' } }); + assert.deepStrictEqual(data.default.items, [1, 2, 3]); + assert.strictEqual(data.default.enabled, true); + + myVfs.unmount(); +} + +// Test that real modules still work when VFS is mounted +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/test.mjs', 'export const x = 1;'); + myVfs.mount('/virtual6'); + + // Import from node: should still work + const assertMod = await import('node:assert'); + assert.strictEqual(typeof assertMod.strictEqual, 'function'); + + myVfs.unmount(); +} + +// Test mixed CJS and ESM - ESM importing from VFS while CJS also works +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/esm-module.mjs', 'export const esmValue = "esm";'); + myVfs.writeFileSync('/cjs-module.js', 'module.exports = { cjsValue: "cjs" };'); + myVfs.mount('/virtual8'); + + const { esmValue } = await import('/virtual8/esm-module.mjs'); + assert.strictEqual(esmValue, 'esm'); + + // CJS require should also work (via createRequire) + const { createRequire } = await import('module'); + const require = createRequire(import.meta.url); + const { cjsValue } = require('/virtual8/cjs-module.js'); + assert.strictEqual(cjsValue, 'cjs'); + + myVfs.unmount(); +} diff --git a/test/parallel/test-vfs-overlay.js b/test/parallel/test-vfs-overlay.js new file mode 100644 index 00000000000000..9a27139ac60b60 --- /dev/null +++ b/test/parallel/test-vfs-overlay.js @@ -0,0 +1,241 @@ +'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-overlay'); +fs.mkdirSync(testDir, { recursive: true }); + +// Create real files for testing +fs.writeFileSync(path.join(testDir, 'real.txt'), 'real content'); +fs.writeFileSync(path.join(testDir, 'both.txt'), 'real both'); +fs.mkdirSync(path.join(testDir, 'subdir')); +fs.writeFileSync(path.join(testDir, 'subdir', 'nested.txt'), 'nested real'); + +// Test overlay mode property +{ + const normalVfs = vfs.create(); + assert.strictEqual(normalVfs.overlay, false); + + const overlayVfs = vfs.create({ overlay: true }); + assert.strictEqual(overlayVfs.overlay, true); +} + +// Test overlay mode: only intercepts files that exist in VFS +{ + const overlayVfs = vfs.create(new vfs.MemoryProvider(), { overlay: true }); + + // Add a file to VFS that also exists on real fs + overlayVfs.writeFileSync('/both.txt', 'virtual both'); + + // Add a file that only exists in VFS + overlayVfs.writeFileSync('/virtual-only.txt', 'only in vfs'); + + // Mount at the test directory path + overlayVfs.mount(testDir); + + // File exists in VFS - should be intercepted + assert.strictEqual(overlayVfs.shouldHandle(path.join(testDir, 'both.txt')), true); + assert.strictEqual(overlayVfs.shouldHandle(path.join(testDir, 'virtual-only.txt')), true); + + // File only exists on real fs - should NOT be intercepted in overlay mode + assert.strictEqual(overlayVfs.shouldHandle(path.join(testDir, 'real.txt')), false); + + // Non-existent file - should NOT be intercepted + assert.strictEqual(overlayVfs.shouldHandle(path.join(testDir, 'nonexistent.txt')), false); + + overlayVfs.unmount(); +} + +// Test overlay mode with fs module integration +{ + const overlayVfs = vfs.create(new vfs.MemoryProvider(), { overlay: true }); + + // Mock a specific file + overlayVfs.writeFileSync('/both.txt', 'mocked content'); + + overlayVfs.mount(testDir); + + // Mocked file returns VFS content + const mockedContent = fs.readFileSync(path.join(testDir, 'both.txt'), 'utf8'); + assert.strictEqual(mockedContent, 'mocked content'); + + // Real file (not in VFS) returns real content + const realContent = fs.readFileSync(path.join(testDir, 'real.txt'), 'utf8'); + assert.strictEqual(realContent, 'real content'); + + // Nested real file also works + const nestedContent = fs.readFileSync(path.join(testDir, 'subdir', 'nested.txt'), 'utf8'); + assert.strictEqual(nestedContent, 'nested real'); + + overlayVfs.unmount(); +} + +// Test non-overlay mode (default): intercepts all paths under mount point +{ + const normalVfs = vfs.create(new vfs.MemoryProvider()); + + // Add one file + normalVfs.writeFileSync('/some-file.txt', 'vfs content'); + + normalVfs.mount(testDir); + + // All paths under mount point should be handled, even if they don't exist in VFS + assert.strictEqual(normalVfs.shouldHandle(path.join(testDir, 'some-file.txt')), true); + assert.strictEqual(normalVfs.shouldHandle(path.join(testDir, 'real.txt')), true); + assert.strictEqual(normalVfs.shouldHandle(path.join(testDir, 'nonexistent.txt')), true); + + // Paths outside mount point should not be handled + assert.strictEqual(normalVfs.shouldHandle('/other/path'), false); + + normalVfs.unmount(); +} + +// Test overlay mode with directories +{ + const overlayVfs = vfs.create(new vfs.MemoryProvider(), { overlay: true }); + + // Create a directory in VFS + overlayVfs.mkdirSync('/vfs-dir'); + overlayVfs.writeFileSync('/vfs-dir/file.txt', 'vfs file'); + + overlayVfs.mount(testDir); + + // VFS directory should be handled + assert.strictEqual(overlayVfs.shouldHandle(path.join(testDir, 'vfs-dir')), true); + assert.strictEqual(overlayVfs.shouldHandle(path.join(testDir, 'vfs-dir', 'file.txt')), true); + + // Real directory should NOT be handled in overlay mode + assert.strictEqual(overlayVfs.shouldHandle(path.join(testDir, 'subdir')), false); + + overlayVfs.unmount(); +} + +// Test overlay mode with require/import (async) +(async () => { + const overlayVfs = vfs.create(new vfs.MemoryProvider(), { overlay: true }); + + // Create a module in VFS + overlayVfs.writeFileSync('/mock-module.js', 'module.exports = "mocked";'); + + overlayVfs.mount(testDir); + + // Require the mocked module + const result = require(path.join(testDir, 'mock-module.js')); + assert.strictEqual(result, 'mocked'); + + overlayVfs.unmount(); + + // Clean up module cache + delete require.cache[path.join(testDir, 'mock-module.js')]; +})().then(common.mustCall()); + +// Test overlay mode validates boolean option +{ + assert.throws(() => { + vfs.create({ overlay: 'true' }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + }); + + assert.throws(() => { + vfs.create({ overlay: 1 }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + }); +} + +// Test overlay mode with virtualCwd and chdir +// In overlay mode, chdir to a directory that exists only on real fs falls through +{ + const overlayVfs = vfs.create(new vfs.MemoryProvider(), { + overlay: true, + virtualCwd: true, + }); + + // Create a directory only in VFS + overlayVfs.mkdirSync('/vfs-only-dir'); + + overlayVfs.mount(testDir); + + // Chdir to VFS directory works (exists in VFS) + overlayVfs.chdir(path.join(testDir, 'vfs-only-dir')); + assert.strictEqual(overlayVfs.cwd(), path.join(testDir, 'vfs-only-dir')); + + // In overlay mode, shouldHandle returns false for real-only directories + // So chdir to real directory would fall through to process.chdir + // We verify this by checking shouldHandle + assert.strictEqual(overlayVfs.shouldHandle(path.join(testDir, 'subdir')), false); + + // The mountpoint itself should always be handled (root exists) + assert.strictEqual(overlayVfs.shouldHandle(testDir), true); + + overlayVfs.unmount(); +} + +// Test overlay mode in worker thread scenario +// This verifies the behavior documented: overlay allows selective mocking +{ + const { Worker, isMainThread } = require('worker_threads'); + + if (isMainThread) { + // Main thread: create worker with overlay VFS test + const workerCode = ` + const { workerData, parentPort } = require('worker_threads'); + const vfs = require('node:vfs'); + const fs = require('fs'); + const path = require('path'); + + const testDir = workerData.testDir; + + // In worker: create overlay VFS that mocks specific files + const overlayVfs = vfs.create(new vfs.MemoryProvider(), { + overlay: true, + virtualCwd: true, + }); + + // Mock a specific config file + overlayVfs.mkdirSync('/config'); + overlayVfs.writeFileSync('/config/app.json', '{"env": "test"}'); + + overlayVfs.mount(testDir); + + // Read mocked file + const mockedConfig = fs.readFileSync(path.join(testDir, 'config', 'app.json'), 'utf8'); + + // Read real file (not mocked) - should work in overlay mode + const realFile = fs.readFileSync(path.join(testDir, 'real.txt'), 'utf8'); + + // Test chdir to VFS directory + overlayVfs.chdir(path.join(testDir, 'config')); + const cwd = overlayVfs.cwd(); + + overlayVfs.unmount(); + + parentPort.postMessage({ + mockedConfig, + realFile, + cwd, + }); + `; + + const worker = new Worker(workerCode, { + eval: true, + workerData: { testDir }, + }); + + worker.on('message', common.mustCall((msg) => { + assert.strictEqual(msg.mockedConfig, '{"env": "test"}'); + assert.strictEqual(msg.realFile, 'real content'); + assert.strictEqual(msg.cwd, path.join(testDir, 'config')); + })); + + worker.on('error', common.mustNotCall()); + } +} diff --git a/test/parallel/test-vfs-promises.js b/test/parallel/test-vfs-promises.js new file mode 100644 index 00000000000000..bb4d583083e9a8 --- /dev/null +++ b/test/parallel/test-vfs-promises.js @@ -0,0 +1,270 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); + +// Test callback-based readFile +{ + const myVfs = fs.createVirtual(); + 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 = fs.createVirtual(); + + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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'); + })); +} + +// ==================== Promise API Tests ==================== + +// Test promises.readFile +(async () => { + const myVfs = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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()); diff --git a/test/parallel/test-vfs-real-provider.js b/test/parallel/test-vfs-real-provider.js new file mode 100644 index 00000000000000..9fc1abd20dfd88 --- /dev/null +++ b/test/parallel/test-vfs-real-provider.js @@ -0,0 +1,234 @@ +'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 mounting RealFSProvider +{ + const realVfs = vfs.create(new vfs.RealFSProvider(testDir)); + + fs.writeFileSync(path.join(testDir, 'mounted.txt'), 'mounted content'); + + realVfs.mount('/virtual'); + + // Now should be able to read through standard fs + const content = fs.readFileSync('/virtual/mounted.txt', 'utf8'); + assert.strictEqual(content, 'mounted content'); + + realVfs.unmount(); + + // Clean up + fs.unlinkSync(path.join(testDir, 'mounted.txt')); +} + +// 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')); +} diff --git a/test/parallel/test-vfs-require.js b/test/parallel/test-vfs-require.js new file mode 100644 index 00000000000000..f343c4d2d565a4 --- /dev/null +++ b/test/parallel/test-vfs-require.js @@ -0,0 +1,163 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const fs = require('fs'); + +// Test requiring a simple virtual module +// VFS internal path: /hello.js +// Mount point: /virtual +// External path: /virtual/hello.js +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/hello.js', 'module.exports = "hello from vfs";'); + myVfs.mount('/virtual'); + + const result = require('/virtual/hello.js'); + assert.strictEqual(result, 'hello from vfs'); + + myVfs.unmount(); +} + +// Test requiring a virtual module that exports an object +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/config.js', ` + module.exports = { + name: 'test-config', + version: '1.0.0', + getValue: function() { return 42; } + }; + `); + myVfs.mount('/virtual2'); + + const config = require('/virtual2/config.js'); + assert.strictEqual(config.name, 'test-config'); + assert.strictEqual(config.version, '1.0.0'); + assert.strictEqual(config.getValue(), 42); + + myVfs.unmount(); +} + +// Test requiring a virtual module that requires another virtual module +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/utils.js', ` + module.exports = { + add: function(a, b) { return a + b; } + }; + `); + myVfs.writeFileSync('/main.js', ` + const utils = require('/virtual3/utils.js'); + module.exports = { + sum: utils.add(10, 20) + }; + `); + myVfs.mount('/virtual3'); + + const main = require('/virtual3/main.js'); + assert.strictEqual(main.sum, 30); + + myVfs.unmount(); +} + +// Test requiring a JSON file from VFS +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/data.json', JSON.stringify({ + items: [1, 2, 3], + enabled: true, + })); + myVfs.mount('/virtual4'); + + const data = require('/virtual4/data.json'); + assert.deepStrictEqual(data.items, [1, 2, 3]); + assert.strictEqual(data.enabled, true); + + myVfs.unmount(); +} + +// Test virtual package.json resolution +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/my-package', { recursive: true }); + myVfs.writeFileSync('/my-package/package.json', JSON.stringify({ + name: 'my-package', + main: 'index.js', + })); + myVfs.writeFileSync('/my-package/index.js', ` + module.exports = { loaded: true }; + `); + myVfs.mount('/virtual5'); + + const pkg = require('/virtual5/my-package'); + assert.strictEqual(pkg.loaded, true); + + myVfs.unmount(); +} + +// Test that real modules still work when VFS is mounted +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/test.js', 'module.exports = 1;'); + myVfs.mount('/virtual6'); + + // require('assert') should still work (builtin) + assert.strictEqual(typeof assert.strictEqual, 'function'); + + // Real file requires should still work + const commonMod = require('../common'); + assert.ok(commonMod); + + myVfs.unmount(); +} + +// Test require with relative paths inside VFS module +{ + const myVfs = fs.createVirtual(); + myVfs.mkdirSync('/lib', { recursive: true }); + myVfs.writeFileSync('/lib/helper.js', ` + module.exports = { help: function() { return 'helped'; } }; + `); + myVfs.writeFileSync('/lib/index.js', ` + const helper = require('./helper.js'); + module.exports = helper.help(); + `); + myVfs.mount('/virtual8'); + + const result = require('/virtual8/lib/index.js'); + assert.strictEqual(result, 'helped'); + + myVfs.unmount(); +} + +// Test fs.readFileSync interception when VFS is active +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/file.txt', 'virtual content'); + myVfs.mount('/virtual9'); + + const content = fs.readFileSync('/virtual9/file.txt', 'utf8'); + assert.strictEqual(content, 'virtual content'); + + myVfs.unmount(); +} + +// Test that unmounting stops interception +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/unmount-test.js', 'module.exports = "before unmount";'); + myVfs.mount('/virtual10'); + + const result = require('/virtual10/unmount-test.js'); + assert.strictEqual(result, 'before unmount'); + + myVfs.unmount(); + + // After unmounting, the file should not be found + assert.throws(() => { + // Clear require cache first + delete require.cache['/virtual10/unmount-test.js']; + require('/virtual10/unmount-test.js'); + }, { code: 'MODULE_NOT_FOUND' }); +} diff --git a/test/parallel/test-vfs-sea.js b/test/parallel/test-vfs-sea.js new file mode 100644 index 00000000000000..4b57897f8da57e --- /dev/null +++ b/test/parallel/test-vfs-sea.js @@ -0,0 +1,48 @@ +'use strict'; + +// Tests for SEA VFS functions when NOT running as a Single Executable Application. +// For full SEA VFS integration tests, see test/sea/test-single-executable-application-vfs.js + +require('../common'); +const assert = require('assert'); +const sea = require('node:sea'); + +// Test that SEA functions are exported from sea module +assert.strictEqual(typeof sea.getVfs, 'function'); +assert.strictEqual(typeof sea.hasAssets, 'function'); + +// Test hasSeaAssets() returns false when not running as SEA +{ + const hasAssets = sea.hasAssets(); + assert.strictEqual(hasAssets, false); +} + +// Test getSeaVfs() returns null when not running as SEA +{ + const seaVfs = sea.getVfs(); + assert.strictEqual(seaVfs, null); +} + +// Test getSeaVfs() with options still returns null when not in SEA +{ + const seaVfs = sea.getVfs({ prefix: '/custom-sea' }); + assert.strictEqual(seaVfs, null); +} + +{ + const seaVfs = sea.getVfs({ moduleHooks: false }); + assert.strictEqual(seaVfs, null); +} + +{ + const seaVfs = sea.getVfs({ prefix: '/my-app', moduleHooks: true }); + assert.strictEqual(seaVfs, null); +} + +// Verify that calling getSeaVfs multiple times is safe (caching behavior) +{ + const vfs1 = sea.getVfs(); + const vfs2 = sea.getVfs(); + assert.strictEqual(vfs1, vfs2); + assert.strictEqual(vfs1, null); +} diff --git a/test/parallel/test-vfs-streams.js b/test/parallel/test-vfs-streams.js new file mode 100644 index 00000000000000..6675caee7521fa --- /dev/null +++ b/test/parallel/test-vfs-streams.js @@ -0,0 +1,212 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); + +// Test basic createReadStream +{ + const myVfs = fs.createVirtual(); + myVfs.writeFileSync('/file.txt', 'hello world'); + + const stream = myVfs.createReadStream('/file.txt'); + let data = ''; + + stream.on('open', common.mustCall((fd) => { + assert.ok(fd >= 10000); + })); + + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + + const stream = myVfs.createReadStream('/nonexistent.txt'); + + stream.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + })); +} + +// Test createReadStream path property +{ + const myVfs = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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 = fs.createVirtual(); + 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-symlinks.js b/test/parallel/test-vfs-symlinks.js new file mode 100644 index 00000000000000..c68e47a7e872d4 --- /dev/null +++ b/test/parallel/test-vfs-symlinks.js @@ -0,0 +1,334 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); + +// Test basic symlink creation +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/target.txt', 'Hello, World!'); + vfs.symlinkSync('/target.txt', '/link.txt'); + vfs.mount('/virtual'); + + // Verify symlink exists + assert.strictEqual(vfs.existsSync('/virtual/link.txt'), true); + + vfs.unmount(); +} + +// Test reading file through symlink +{ + const vfs = fs.createVirtual(); + vfs.mkdirSync('/data', { recursive: true }); + vfs.writeFileSync('/data/file.txt', 'File content'); + vfs.symlinkSync('/data/file.txt', '/shortcut'); + vfs.mount('/virtual'); + + const content = vfs.readFileSync('/virtual/shortcut', 'utf8'); + assert.strictEqual(content, 'File content'); + + vfs.unmount(); +} + +// Test statSync follows symlinks (returns target's stats) +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/real.txt', 'x'.repeat(100)); + vfs.symlinkSync('/real.txt', '/link.txt'); + vfs.mount('/virtual'); + + const statLink = vfs.statSync('/virtual/link.txt'); + const statReal = vfs.statSync('/virtual/real.txt'); + + // Both should have the same size (the file's size) + assert.strictEqual(statLink.size, 100); + assert.strictEqual(statLink.size, statReal.size); + + // statSync should show it's a file, not a symlink + assert.strictEqual(statLink.isFile(), true); + assert.strictEqual(statLink.isSymbolicLink(), false); + + vfs.unmount(); +} + +// Test lstatSync does NOT follow symlinks +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/real.txt', 'x'.repeat(100)); + vfs.symlinkSync('/real.txt', '/link.txt'); + vfs.mount('/virtual'); + + const lstat = vfs.lstatSync('/virtual/link.txt'); + + // Lstat should show it's a symlink + assert.strictEqual(lstat.isSymbolicLink(), true); + assert.strictEqual(lstat.isFile(), false); + + // Size should be the length of the target path + assert.strictEqual(lstat.size, '/real.txt'.length); + + vfs.unmount(); +} + +// Test readlinkSync returns symlink target +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/target.txt', 'content'); + vfs.symlinkSync('/target.txt', '/link.txt'); + vfs.mount('/virtual'); + + const target = vfs.readlinkSync('/virtual/link.txt'); + assert.strictEqual(target, '/target.txt'); + + vfs.unmount(); +} + +// Test readlinkSync throws EINVAL for non-symlinks +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/file.txt', 'content'); + vfs.mount('/virtual'); + + assert.throws(() => { + vfs.readlinkSync('/virtual/file.txt'); + }, { code: 'EINVAL' }); + + vfs.unmount(); +} + +// Test symlink to directory +{ + const vfs = fs.createVirtual(); + vfs.mkdirSync('/data', { recursive: true }); + vfs.writeFileSync('/data/file.txt', 'content'); + vfs.symlinkSync('/data', '/shortcut'); + vfs.mount('/virtual'); + + // Reading through symlink directory + const content = vfs.readFileSync('/virtual/shortcut/file.txt', 'utf8'); + assert.strictEqual(content, 'content'); + + // Listing symlinked directory + const files = vfs.readdirSync('/virtual/shortcut'); + assert.deepStrictEqual(files, ['file.txt']); + + vfs.unmount(); +} + +// Test relative symlinks +{ + const vfs = fs.createVirtual(); + vfs.mkdirSync('/dir', { recursive: true }); + vfs.writeFileSync('/dir/file.txt', 'content'); + vfs.symlinkSync('file.txt', '/dir/link.txt'); // Relative target + vfs.mount('/virtual'); + + const content = vfs.readFileSync('/virtual/dir/link.txt', 'utf8'); + assert.strictEqual(content, 'content'); + + // Readlink should return the relative target as-is + const target = vfs.readlinkSync('/virtual/dir/link.txt'); + assert.strictEqual(target, 'file.txt'); + + vfs.unmount(); +} + +// Test symlink chains (symlink pointing to another symlink) +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/file.txt', 'chained'); + vfs.symlinkSync('/file.txt', '/link1'); + vfs.symlinkSync('/link1', '/link2'); + vfs.symlinkSync('/link2', '/link3'); + vfs.mount('/virtual'); + + // Should resolve through all symlinks + const content = vfs.readFileSync('/virtual/link3', 'utf8'); + assert.strictEqual(content, 'chained'); + + vfs.unmount(); +} + +// Test realpathSync resolves symlinks +{ + const vfs = fs.createVirtual(); + vfs.mkdirSync('/actual', { recursive: true }); + vfs.writeFileSync('/actual/file.txt', 'content'); + vfs.symlinkSync('/actual', '/link'); + vfs.mount('/virtual'); + + const realpath = vfs.realpathSync('/virtual/link/file.txt'); + assert.strictEqual(realpath, '/virtual/actual/file.txt'); + + vfs.unmount(); +} + +// Test symlink loop detection (ELOOP) +{ + const vfs = fs.createVirtual(); + vfs.symlinkSync('/loop2', '/loop1'); + vfs.symlinkSync('/loop1', '/loop2'); + vfs.mount('/virtual'); + + // statSync should throw ELOOP + assert.throws(() => { + vfs.statSync('/virtual/loop1'); + }, { code: 'ELOOP' }); + + // realpathSync should throw ELOOP + assert.throws(() => { + vfs.realpathSync('/virtual/loop1'); + }, { code: 'ELOOP' }); + + // lstatSync should still work (doesn't follow symlinks) + const lstat = vfs.lstatSync('/virtual/loop1'); + assert.strictEqual(lstat.isSymbolicLink(), true); + + vfs.unmount(); +} + +// Test readdirSync with withFileTypes includes symlinks +{ + const vfs = fs.createVirtual(); + vfs.mkdirSync('/dir/subdir', { recursive: true }); + vfs.writeFileSync('/dir/file.txt', 'content'); + vfs.symlinkSync('/dir/file.txt', '/dir/link'); + vfs.mount('/virtual'); + + const entries = vfs.readdirSync('/virtual/dir', { withFileTypes: true }); + + const file = entries.find((e) => e.name === 'file.txt'); + const subdir = entries.find((e) => e.name === 'subdir'); + const link = entries.find((e) => e.name === 'link'); + + assert.strictEqual(file.isFile(), true); + assert.strictEqual(subdir.isDirectory(), true); + assert.strictEqual(link.isSymbolicLink(), true); + + vfs.unmount(); +} + +// Test async readlink +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/target', 'content'); + vfs.symlinkSync('/target', '/link'); + vfs.mount('/virtual'); + + vfs.readlink('/virtual/link', common.mustSucceed((target) => { + assert.strictEqual(target, '/target'); + vfs.unmount(); + })); +} + +// Test async realpath with symlinks +{ + const vfs = fs.createVirtual(); + vfs.mkdirSync('/real', { recursive: true }); + vfs.writeFileSync('/real/file.txt', 'content'); + vfs.symlinkSync('/real', '/link'); + vfs.mount('/virtual'); + + vfs.realpath('/virtual/link/file.txt', common.mustSucceed((resolvedPath) => { + assert.strictEqual(resolvedPath, '/virtual/real/file.txt'); + vfs.unmount(); + })); +} + +// Test promises API - stat follows symlinks +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/file.txt', 'x'.repeat(50)); + vfs.symlinkSync('/file.txt', '/link.txt'); + vfs.mount('/virtual'); + + (async () => { + const stat = await vfs.promises.stat('/virtual/link.txt'); + assert.strictEqual(stat.isFile(), true); + assert.strictEqual(stat.size, 50); + vfs.unmount(); + })().then(common.mustCall()); +} + +// Test promises API - lstat does not follow symlinks +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/file.txt', 'x'.repeat(50)); + vfs.symlinkSync('/file.txt', '/link.txt'); + vfs.mount('/virtual'); + + (async () => { + const lstat = await vfs.promises.lstat('/virtual/link.txt'); + assert.strictEqual(lstat.isSymbolicLink(), true); + vfs.unmount(); + })().then(common.mustCall()); +} + +// Test promises API - readlink +{ + const vfs = fs.createVirtual(); + vfs.writeFileSync('/target', 'content'); + vfs.symlinkSync('/target', '/link'); + vfs.mount('/virtual'); + + (async () => { + const target = await vfs.promises.readlink('/virtual/link'); + assert.strictEqual(target, '/target'); + vfs.unmount(); + })().then(common.mustCall()); +} + +// Test promises API - realpath resolves symlinks +{ + const vfs = fs.createVirtual(); + vfs.mkdirSync('/real', { recursive: true }); + vfs.writeFileSync('/real/file.txt', 'content'); + vfs.symlinkSync('/real', '/link'); + vfs.mount('/virtual'); + + (async () => { + const resolved = await vfs.promises.realpath('/virtual/link/file.txt'); + assert.strictEqual(resolved, '/virtual/real/file.txt'); + vfs.unmount(); + })().then(common.mustCall()); +} + +// Test broken symlink (target doesn't exist) +{ + const vfs = fs.createVirtual(); + vfs.symlinkSync('/nonexistent', '/broken'); + vfs.mount('/virtual'); + + // statSync should throw ENOENT for broken symlink + assert.throws(() => { + vfs.statSync('/virtual/broken'); + }, { code: 'ENOENT' }); + + // lstatSync should work (the symlink itself exists) + const lstat = vfs.lstatSync('/virtual/broken'); + assert.strictEqual(lstat.isSymbolicLink(), true); + + // readlinkSync should work (returns target path) + const target = vfs.readlinkSync('/virtual/broken'); + assert.strictEqual(target, '/nonexistent'); + + vfs.unmount(); +} + +// Test symlink with parent traversal (..) +{ + const vfs = fs.createVirtual(); + vfs.mkdirSync('/a', { recursive: true }); + vfs.mkdirSync('/b', { recursive: true }); + vfs.writeFileSync('/a/file.txt', 'content'); + vfs.symlinkSync('../a/file.txt', '/b/link'); + vfs.mount('/virtual'); + + const content = vfs.readFileSync('/virtual/b/link', 'utf8'); + assert.strictEqual(content, 'content'); + + vfs.unmount(); +} + +console.log('All VFS symlink tests passed!'); diff --git a/test/sea/test-single-executable-application-vfs.js b/test/sea/test-single-executable-application-vfs.js new file mode 100644 index 00000000000000..82f5e8f215d878 --- /dev/null +++ b/test/sea/test-single-executable-application-vfs.js @@ -0,0 +1,32 @@ +'use strict'; + +// This tests the SEA VFS integration - sea.getVfs() and sea.hasAssets() + +require('../common'); + +const { + buildSEA, + skipIfBuildSEAIsNotSupported, +} = require('../common/sea'); + +skipIfBuildSEAIsNotSupported(); + +const tmpdir = require('../common/tmpdir'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const fixtures = require('../common/fixtures'); + +tmpdir.refresh(); +const outputFile = buildSEA(fixtures.path('sea', 'vfs')); + +spawnSyncAndAssert( + outputFile, + { + env: { + ...process.env, + NODE_DEBUG_NATIVE: undefined, + }, + }, + { + stdout: /All SEA VFS tests passed!/, + }, +); diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index 5af6e86651af28..75ce7cfbb5ff51 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -166,6 +166,12 @@ const customTypesMap = { 'fs.StatWatcher': 'fs.html#class-fsstatwatcher', 'fs.WriteStream': 'fs.html#class-fswritestream', + 'VirtualFileSystem': 'vfs.html#class-virtualfilesystem', + 'VirtualProvider': 'vfs.html#class-virtualprovider', + 'MemoryProvider': 'vfs.html#class-memoryprovider', + 'SEAProvider': 'vfs.html#class-seaprovider', + 'RealFSProvider': 'vfs.html#class-realfsprovider', + 'http.Agent': 'http.html#class-httpagent', 'http.ClientRequest': 'http.html#class-httpclientrequest', 'http.IncomingMessage': 'http.html#class-httpincomingmessage', @@ -251,6 +257,7 @@ const customTypesMap = { 'Timer': 'timers.html#timers', 'TestsStream': 'test.html#class-testsstream', + 'MockFSContext': 'test.html#class-mockfscontext', 'tls.SecureContext': 'tls.html#tlscreatesecurecontextoptions', 'tls.Server': 'tls.html#class-tlsserver',