From 28ac77e5f82626a9f98074c5e383231728776b59 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 23 Jan 2026 00:26:10 +0100 Subject: [PATCH 01/19] fs: add virtual file system support Add a read-only virtual file system (VFS) that can be mounted at a specific path prefix, enabling standard fs APIs to work transparently with in-memory files. Key features: - fs.createVirtual() to create VFS instances - Support for files, directories, and symbolic links - Full async/sync/promise API support (readFile, stat, readdir, etc.) - File descriptor operations (open, read, close) - createReadStream() support - fs.glob() integration - CJS require() and ESM import() support via module hooks - Virtual process.chdir() for relative path resolution - SEA integration via sea.getVfs() and sea.hasAssets() - Test runner mock.fs() for file system mocking The VFS is read-only by design and uses virtual file descriptors (10000+) to avoid conflicts with real file descriptors. --- doc/api/fs.md | 590 ++++++++ doc/api/single-executable-applications.md | 89 ++ doc/api/test.md | 88 ++ lib/fs.js | 14 + lib/internal/test_runner/mock/mock.js | 109 ++ lib/internal/vfs/entries.js | 350 +++++ lib/internal/vfs/errors.js | 157 ++ lib/internal/vfs/fd.js | 166 ++ lib/internal/vfs/module_hooks.js | 600 ++++++++ lib/internal/vfs/router.js | 134 ++ lib/internal/vfs/sea.js | 94 ++ lib/internal/vfs/stats.js | 196 +++ lib/internal/vfs/streams.js | 161 ++ lib/internal/vfs/virtual_fs.js | 1340 +++++++++++++++++ lib/sea.js | 7 + test/parallel/test-permission-fs-supported.js | 2 + test/parallel/test-runner-mock-fs.js | 236 +++ test/parallel/test-vfs-basic.js | 214 +++ test/parallel/test-vfs-chdir-worker.js | 105 ++ test/parallel/test-vfs-chdir.js | 234 +++ test/parallel/test-vfs-fd.js | 318 ++++ test/parallel/test-vfs-glob.js | 196 +++ test/parallel/test-vfs-import.mjs | 147 ++ test/parallel/test-vfs-promises.js | 299 ++++ test/parallel/test-vfs-require.js | 204 +++ test/parallel/test-vfs-sea.js | 48 + test/parallel/test-vfs-streams.js | 234 +++ test/parallel/test-vfs-symlinks.js | 346 +++++ .../test-single-executable-application-vfs.js | 144 ++ tools/doc/type-parser.mjs | 2 + 30 files changed, 6824 insertions(+) create mode 100644 lib/internal/vfs/entries.js create mode 100644 lib/internal/vfs/errors.js create mode 100644 lib/internal/vfs/fd.js create mode 100644 lib/internal/vfs/module_hooks.js create mode 100644 lib/internal/vfs/router.js create mode 100644 lib/internal/vfs/sea.js create mode 100644 lib/internal/vfs/stats.js create mode 100644 lib/internal/vfs/streams.js create mode 100644 lib/internal/vfs/virtual_fs.js create mode 100644 test/parallel/test-runner-mock-fs.js create mode 100644 test/parallel/test-vfs-basic.js create mode 100644 test/parallel/test-vfs-chdir-worker.js create mode 100644 test/parallel/test-vfs-chdir.js create mode 100644 test/parallel/test-vfs-fd.js create mode 100644 test/parallel/test-vfs-glob.js create mode 100644 test/parallel/test-vfs-import.mjs create mode 100644 test/parallel/test-vfs-promises.js create mode 100644 test/parallel/test-vfs-require.js create mode 100644 test/parallel/test-vfs-sea.js create mode 100644 test/parallel/test-vfs-streams.js create mode 100644 test/parallel/test-vfs-symlinks.js create mode 100644 test/sea/test-single-executable-application-vfs.js 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/single-executable-applications.md b/doc/api/single-executable-applications.md index 611700a7a4bf1e..6afc64e895dceb 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -174,6 +174,94 @@ 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 + +The default `require()` function in a SEA only supports loading Node.js +built-in modules. To load JavaScript modules bundled as assets, you must use +[`module.createRequire()`][]: + +```cjs +const { createRequire } = require('node:module'); +const sea = require('node:sea'); + +// Initialize VFS +sea.getVfs(); + +// Create a require function that works with VFS +const seaRequire = createRequire('/sea/'); + +// Now you can require bundled modules +const myModule = seaRequire('/sea/lib/mymodule.js'); +const utils = seaRequire('/sea/utils/helpers.js'); +``` + +This is necessary because SEA uses a special embedder require that doesn't go +through the standard module resolution hooks that VFS registers. + +#### 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 @@ -604,6 +692,7 @@ to help us document them. [Mach-O]: https://en.wikipedia.org/wiki/Mach-O [PE]: https://en.wikipedia.org/wiki/Portable_Executable [Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/ +[`module.createRequire()`]: module.md#modulecreaterequirefilename [`process.execPath`]: process.md#processexecpath [`require()`]: modules.md#requireid [`require.main`]: modules.md#accessing-the-main-module 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 Node.js's `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 + +## 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`. +* Returns: {VirtualFileSystem} + +Creates a new `VirtualFileSystem` instance. If no provider is specified, a +`MemoryProvider` is used, which stores files in memory. + +```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 }); +``` + +## `vfs.createSEA([options])` + + + +* `options` {Object} + * `mountPoint` {string} The path prefix where SEA assets will be mounted. + **Default:** `'/sea'`. + * `moduleHooks` {boolean} Whether to enable module loading hooks. + **Default:** `true`. + * `virtualCwd` {boolean} Whether to enable virtual working directory. + **Default:** `false`. +* Returns: {VirtualFileSystem | null} Returns `null` if not running as a + Single Executable Application. + +Creates a `VirtualFileSystem` pre-configured with SEA (Single Executable +Application) assets. This is a convenience method for accessing bundled assets +in SEA builds. + +```cjs +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +const seaVfs = vfs.createSEA({ mountPoint: '/assets' }); +if (seaVfs) { + // Running as SEA - assets are available + const data = fs.readFileSync('/assets/config.json', 'utf8'); +} +``` + +## 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.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. + +```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. + +### `vfs.isMounted()` + + + +* Returns: {boolean} + +Returns `true` if the VFS is currently mounted. + +### `vfs.mountPoint` + + + +* {string | null} + +The current mount point, or `null` if not mounted. + +### `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. + +### `vfs.cwd()` + + + +* Returns: {string} + +Returns the current virtual working directory. + +### 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.readFileSync(path[, options])` - Read a file +* `vfs.writeFileSync(path, data[, options])` - Write a file +* `vfs.appendFileSync(path, data[, options])` - Append to a file +* `vfs.statSync(path[, options])` - Get file stats +* `vfs.lstatSync(path[, options])` - Get file stats (no symlink follow) +* `vfs.readdirSync(path[, options])` - Read directory contents +* `vfs.mkdirSync(path[, options])` - Create a directory +* `vfs.rmdirSync(path)` - Remove a directory +* `vfs.unlinkSync(path)` - Remove a file +* `vfs.renameSync(oldPath, newPath)` - Rename a file or directory +* `vfs.copyFileSync(src, dest[, mode])` - Copy a file +* `vfs.existsSync(path)` - Check if path exists +* `vfs.accessSync(path[, mode])` - Check file accessibility +* `vfs.openSync(path, flags[, mode])` - Open a file +* `vfs.closeSync(fd)` - Close a file descriptor +* `vfs.readSync(fd, buffer, offset, length, position)` - Read from fd +* `vfs.writeSync(fd, buffer, offset, length, position)` - Write to fd +* `vfs.realpathSync(path[, options])` - Resolve symlinks +* `vfs.readlinkSync(path[, options])` - Read symlink target +* `vfs.symlinkSync(target, path[, type])` - Create a symlink + +#### Promise Methods + +All synchronous methods have promise-based equivalents available through +`vfs.promises`: + +```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' +} +``` + +### Backward Compatibility Methods + +These methods are provided for backward compatibility and convenience: + +#### `vfs.addFile(path, content[, options])` + + + +* `path` {string} The file path. +* `content` {string | Buffer | Function} The file content or a function that + returns content. +* `options` {Object} Optional configuration. + +Adds a file to the VFS. If `content` is a function, it will be called each time +the file is read (dynamic content). + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Static content +myVfs.addFile('/static.txt', 'Static content'); + +// Dynamic content - function is called on each read +let counter = 0; +myVfs.addFile('/counter.txt', () => { + counter++; + return `Count: ${counter}`; +}); + +myVfs.mount('/v'); +const fs = require('node:fs'); +console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 1 +console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 2 +``` + +#### `vfs.addDirectory(path[, populate][, options])` + + + +* `path` {string} The directory path. +* `populate` {Function} Optional callback to lazily populate the directory. +* `options` {Object} Optional configuration. + +Adds a directory to the VFS. If `populate` is provided, it will be called +lazily when the directory is first accessed. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Lazy directory - populated on first access +myVfs.addDirectory('/lazy', (dir) => { + dir.addFile('generated.txt', 'Generated on demand'); + dir.addDirectory('subdir', (subdir) => { + subdir.addFile('nested.txt', 'Nested content'); + }); +}); + +myVfs.mount('/v'); +const fs = require('node:fs'); + +// Directory is populated when first accessed +console.log(fs.readdirSync('/v/lazy')); // ['generated.txt', 'subdir'] +``` + +#### `vfs.addSymlink(path, target[, options])` + + + +* `path` {string} The symlink path. +* `target` {string} The symlink target (can be relative or absolute). +* `options` {Object} Optional configuration. + +Adds a symbolic link to the VFS. + +#### `vfs.has(path)` + + + +* `path` {string} The path to check. +* Returns: {boolean} + +Returns `true` if the path exists in the VFS. + +#### `vfs.remove(path)` + + + +* `path` {string} The path to remove. + +Removes a file or directory from the VFS. + +## 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()); +``` + +## 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'); +} +``` + +## 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: + +```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! +``` + +## Use with Single Executable Applications + +The VFS integrates with Node.js Single Executable Applications to provide +access to bundled assets: + +```cjs +// In your SEA entry script +const vfs = require('node:vfs'); +const fs = require('node:fs'); + +const seaVfs = vfs.createSEA(); +if (seaVfs) { + // Access bundled assets + 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. + +[Single Executable Applications]: single-executable-applications.md diff --git a/lib/fs.js b/lib/fs.js index 3e2d6e8ce7c000..8e7bf9804a7082 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -3207,7 +3207,7 @@ function globSync(pattern, options) { return new Glob(pattern, options).globSync(); } -const lazyVfs = getLazy(() => require('internal/vfs/virtual_fs').VirtualFileSystem); +const lazyVfs = getLazy(() => require('internal/vfs/file_system').VirtualFileSystem); /** * Creates a new virtual file system instance. 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/vfs/fd.js b/lib/internal/vfs/fd.js index 93975ad0fc9173..3bc5811416459b 100644 --- a/lib/internal/vfs/fd.js +++ b/lib/internal/vfs/fd.js @@ -8,9 +8,7 @@ const { // Private symbols const kFd = Symbol('kFd'); const kEntry = Symbol('kEntry'); -const kPosition = Symbol('kPosition'); const kFlags = Symbol('kFlags'); -const kContent = Symbol('kContent'); const kPath = Symbol('kPath'); // FD range: 10000+ to avoid conflicts with real fds @@ -22,21 +20,20 @@ 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 {VirtualFile} entry The virtual file entry + * @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[kPosition] = 0; this[kFlags] = flags; this[kPath] = path; - this[kContent] = null; // Cached content buffer } /** @@ -48,8 +45,8 @@ class VirtualFD { } /** - * Gets the file entry. - * @returns {VirtualFile} + * Gets the file handle. + * @returns {VirtualFileHandle} */ get entry() { return this[kEntry]; @@ -60,7 +57,7 @@ class VirtualFD { * @returns {number} */ get position() { - return this[kPosition]; + return this[kEntry].position; } /** @@ -68,7 +65,7 @@ class VirtualFD { * @param {number} pos The new position */ set position(pos) { - this[kPosition] = pos; + this[kEntry].position = pos; } /** @@ -88,27 +85,25 @@ class VirtualFD { } /** - * Gets or loads the cached content buffer. + * Gets the content buffer synchronously. * @returns {Buffer} */ getContentSync() { - this[kContent] ??= this[kEntry].getContentSync(); - return this[kContent]; + return this[kEntry].readFileSync(); } /** - * Gets or loads the cached content buffer asynchronously. + * Gets the content buffer asynchronously. * @returns {Promise} */ async getContent() { - this[kContent] ??= await this[kEntry].getContent(); - return this[kContent]; + return this[kEntry].readFile(); } } /** * Opens a virtual file and returns its file descriptor. - * @param {VirtualFile} entry The virtual file entry + * @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 diff --git a/lib/internal/vfs/file_handle.js b/lib/internal/vfs/file_handle.js new file mode 100644 index 00000000000000..a014de7263e94d --- /dev/null +++ b/lib/internal/vfs/file_handle.js @@ -0,0 +1,527 @@ +'use strict'; + +const { + MathMin, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +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 Error('read not implemented'); + } + + /** + * 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(); + throw new Error('readSync not implemented'); + } + + /** + * 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 Error('write not implemented'); + } + + /** + * 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(); + throw new Error('writeSync not implemented'); + } + + /** + * Reads the entire file. + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(options) { + this._checkClosed(); + throw new Error('readFile not implemented'); + } + + /** + * Reads the entire file synchronously. + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(options) { + this._checkClosed(); + throw new Error('readFileSync not implemented'); + } + + /** + * 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 Error('writeFile not implemented'); + } + + /** + * 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 Error('writeFileSync not implemented'); + } + + /** + * Gets file stats. + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(options) { + this._checkClosed(); + throw new Error('stat not implemented'); + } + + /** + * Gets file stats synchronously. + * @param {object} [options] Options + * @returns {Stats} + */ + statSync(options) { + this._checkClosed(); + throw new Error('statSync not implemented'); + } + + /** + * Truncates the file. + * @param {number} [len] The new length + * @returns {Promise} + */ + async truncate(len) { + this._checkClosed(); + throw new Error('truncate not implemented'); + } + + /** + * Truncates the file synchronously. + * @param {number} [len] The new length + */ + truncateSync(len) { + this._checkClosed(); + throw new Error('truncateSync not implemented'); + } + + /** + * 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 && 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 && 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 { 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 { 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 Error('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..acef3e4b9dddd8 --- /dev/null +++ b/lib/internal/vfs/file_system.js @@ -0,0 +1,1092 @@ +'use strict'; + +const { + ObjectFreeze, + Symbol, +} = primordials; + +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { MemoryProvider } = require('internal/vfs/providers/memory'); +const { + normalizePath, + isUnderMountPoint, + getRelativePath, + joinMountPath, + isAbsolutePath, +} = require('internal/vfs/router'); +const { + openVirtualFd, + getVirtualFd, + closeVirtualFd, + isVirtualFd, + VFS_FD_BASE, +} = require('internal/vfs/fd'); +const { + createENOENT, + 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 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'); + +// 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 for backward compat + * @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.fallthrough] Backward compat: Whether to fall through to real fs + */ + constructor(providerOrOptions, options = {}) { + emitExperimentalWarning('VirtualFileSystem'); + + // Handle backward compatibility: first arg can be options object + 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 (backward compat) + options = providerOrOptions; + provider = null; + } + } + + this[kProvider] = provider ?? new MemoryProvider(); + 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 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 isMounted() { + return this[kMounted]; + } + + /** + * Returns true if the provider is read-only. + * @returns {boolean} + */ + get readonly() { + return this[kProvider].readonly; + } + + /** + * Returns true if virtual working directory is enabled. + * @returns {boolean} + */ + get virtualCwdEnabled() { + return this[kVirtualCwdEnabled]; + } + + // ==================== Backward Compatibility Properties ==================== + + /** + * 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]; + } + + // ==================== 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()) { + const { createENOTDIR } = require('internal/vfs/errors'); + 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] || 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]) { + loadModuleHooks(); + registerVFS(this); + } + if (this[kVirtualCwdEnabled]) { + this._hookProcessCwd(); + } + } + + /** + * Enables overlay mode (intercepts all matching paths). + * Backward compatibility method. + */ + 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]) { + 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[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) { + 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. + * @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 + try { + return this[kProvider].existsSync(normalized); + } catch { + return false; + } + } + + 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) { + 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); + } + + // ==================== Backward Compatibility Methods ==================== + + /** + * Adds a file to the VFS. + * Backward compatibility method - use writeFileSync instead. + * @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) { + // Handle dynamic content providers + if (typeof content === 'function') { + // Check if provider supports dynamic content + if (typeof this[kProvider].setContentProvider === 'function') { + this[kProvider].setContentProvider(filePath, content); + } else { + // Fallback: call function once and store result + const result = content(); + this[kProvider].writeFileSync(filePath, result, options); + } + } else { + this[kProvider].writeFileSync(filePath, content, options); + } + } + + /** + * Adds a directory to the VFS. + * Backward compatibility method - use mkdirSync instead. + * @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) { + // Handle dynamic directory population + if (typeof populate === 'function') { + // Check if provider supports lazy population + if (typeof this[kProvider].setPopulateCallback === 'function') { + this[kProvider].setPopulateCallback(dirPath, populate); + } else { + // Fallback: create directory and call populate immediately + this[kProvider].mkdirSync(dirPath, { recursive: true, ...options }); + const scopedVfs = { + addFile: (name, content, opts) => { + const fullPath = dirPath + '/' + name; + this.addFile(fullPath, content, opts); + }, + addDirectory: (name, pop, opts) => { + const fullPath = dirPath + '/' + name; + this.addDirectory(fullPath, pop, opts); + }, + addSymlink: (name, target, opts) => { + const fullPath = dirPath + '/' + name; + this.addSymlink(fullPath, target, opts); + }, + }; + populate(scopedVfs); + } + } else { + this[kProvider].mkdirSync(dirPath, { recursive: true, ...options }); + } + } + + /** + * Adds a symbolic link to the VFS. + * Backward compatibility method - use symlinkSync instead. + * @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) { + this[kProvider].symlinkSync(target, linkPath); + } + + /** + * Removes an entry from the VFS. + * Backward compatibility method - use unlinkSync or rmdirSync instead. + * @param {string} entryPath The absolute path to remove + * @returns {boolean} True if the entry was removed + */ + remove(entryPath) { + try { + const stats = this[kProvider].statSync(entryPath); + if (stats.isDirectory()) { + this[kProvider].rmdirSync(entryPath); + } else { + this[kProvider].unlinkSync(entryPath); + } + return true; + } catch { + return false; + } + } + + /** + * Checks if a path exists in the VFS. + * Backward compatibility method - use existsSync instead. + * @param {string} entryPath The absolute path to check + * @returns {boolean} + */ + has(entryPath) { + return this[kProvider].existsSync(entryPath); + } + + // ==================== 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/provider.js b/lib/internal/vfs/provider.js new file mode 100644 index 00000000000000..bc713c412221c0 --- /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) + * @returns {VirtualFileHandle} + */ + 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 + * @returns {Stats} + */ + 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 + * @returns {string[]|Dirent[]} + */ + 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 + * @returns {string} + */ + 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..487a1b4d755fbf --- /dev/null +++ b/lib/internal/vfs/providers/memory.js @@ -0,0 +1,766 @@ +'use strict'; + +const { + ArrayPrototypePush, + SafeMap, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { MemoryFileHandle } = require('internal/vfs/file_handle'); +const { + createENOENT, + createENOTDIR, + 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 kEntries = Symbol('kEntries'); +const kRoot = Symbol('kRoot'); + +// 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? + this.mtime = Date.now(); + this.ctime = Date.now(); + this.birthtime = Date.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 + const { ERR_INVALID_STATE } = require('internal/errors').codes; + 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; + } + + /** + * Gets the file content (sync version for backward compat). + * @returns {Buffer} The file content + */ + getContent() { + return this.getContentSync(); + } + + /** + * 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(); + } + + get readonly() { + return false; + } + + 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 Error('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 fullPath = path + '/' + name; + if (typeof content === 'function') { + this.setContentProvider(fullPath, content); + } else { + // Create file entry directly + const fileEntry = new MemoryEntry(TYPE_FILE, opts); + fileEntry.content = typeof content === 'string' ? Buffer.from(content) : content; + entry.children.set(name, fileEntry); + } + }, + addDirectory: (name, populate, opts) => { + const fullPath = path + '/' + name; + 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; + } + } + + // === ESSENTIAL PRIMITIVES === + + openSync(path, flags, mode) { + const normalized = this._normalizePath(path); + + // Handle create modes + const isCreate = flags === 'w' || flags === 'w+' || flags === 'a' || flags === 'a+'; + const isWrite = isCreate || flags === 'r+'; + + 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) { + const err = new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`); + err.code = 'ENOTEMPTY'; + err.syscall = 'rmdir'; + err.path = path; + throw err; + } + + 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); + } + + // === SYMLINK OPERATIONS === + + 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); + } + + // === REALPATH === + + 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); + } + + // === DYNAMIC CONTENT === + + /** + * Sets a dynamic content provider for a file. + * The provider function will be called on each read. + * @param {string} path The file path + * @param {Function} contentProvider Function that returns Buffer or string content + */ + setContentProvider(path, contentProvider) { + const normalized = this._normalizePath(path); + + // Ensure parent directories exist and get/create the entry + const parent = this._ensureParent(normalized, true, 'setContentProvider'); + const name = this._getBaseName(normalized); + + let entry = parent.children.get(name); + if (!entry) { + // Create a new file entry + entry = new MemoryEntry(TYPE_FILE); + entry.content = Buffer.alloc(0); // Placeholder + parent.children.set(name, entry); + } + + if (!entry.isFile()) { + throw createEISDIR('setContentProvider', path); + } + + // Set the content provider + entry.contentProvider = contentProvider; + } + + /** + * Sets a lazy populate callback for a directory. + * The callback will be called on first access (readdir, stat child, etc.). + * @param {string} path The directory path + * @param {Function} populateCallback Function that takes a scoped VFS object + */ + setPopulateCallback(path, populateCallback) { + const normalized = this._normalizePath(path); + + // Ensure parent directories exist and get/create the entry + const parent = this._ensureParent(normalized, true, 'setPopulateCallback'); + const name = this._getBaseName(normalized); + + let entry = parent.children.get(name); + if (!entry) { + // Create a new directory entry + entry = new MemoryEntry(TYPE_DIR); + entry.children = new SafeMap(); + parent.children.set(name, entry); + } + + if (!entry.isDirectory()) { + throw createENOTDIR('setPopulateCallback', path); + } + + // Set the populate callback (will be called lazily) + entry.populate = populateCallback; + entry.populated = false; + } +} + +module.exports = { + MemoryProvider, +}; diff --git a/lib/internal/vfs/providers/sea.js b/lib/internal/vfs/providers/sea.js new file mode 100644 index 00000000000000..983933e7651c94 --- /dev/null +++ b/lib/internal/vfs/providers/sea.js @@ -0,0 +1,429 @@ +'use strict'; + +const { + ArrayPrototypePush, + SafeMap, + SafeSet, + StringPrototypeStartsWith, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { VirtualFileHandle } = require('internal/vfs/file_handle'); +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 { MathMin } = primordials; + 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 { 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 Error('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); + } + + // === ESSENTIAL PRIMITIVES === + + 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); + } + + // === WRITE OPERATIONS (all throw EROFS) === + + 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); + } + + // === DEFAULT IMPLEMENTATIONS (read-only overrides) === + + 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/sea.js b/lib/internal/vfs/sea.js index f99da97fd8a417..a5a2c80f3c14e5 100644 --- a/lib/internal/vfs/sea.js +++ b/lib/internal/vfs/sea.js @@ -1,31 +1,17 @@ 'use strict'; -const { - StringPrototypeStartsWith, -} = primordials; - -const { Buffer } = require('buffer'); -const { isSea, getAsset: getAssetInternal, getAssetKeys: getAssetKeysInternal } = internalBinding('sea'); +const { isSea } = internalBinding('sea'); const { kEmptyObject } = require('internal/util'); -// Wrapper to get asset as ArrayBuffer (same as public sea.getAsset without encoding) -function getAsset(key) { - return getAssetInternal(key); -} - -// Wrapper to get asset keys -function getAssetKeys() { - return getAssetKeysInternal() || []; -} - // Lazy-loaded VFS let cachedSeaVfs = null; -// Lazy-load VirtualFileSystem to avoid loading VFS code if not needed +// Lazy-load VirtualFileSystem and SEAProvider to avoid loading VFS code if not needed let VirtualFileSystem; +let SEAProvider; /** - * Creates a VirtualFileSystem populated with SEA assets. + * 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 @@ -37,24 +23,14 @@ function createSeaVfs(options = kEmptyObject) { return null; } - VirtualFileSystem ??= require('internal/vfs/virtual_fs').VirtualFileSystem; + VirtualFileSystem ??= require('internal/vfs/file_system').VirtualFileSystem; + SEAProvider ??= require('internal/vfs/providers/sea').SEAProvider; + const prefix = options.prefix ?? '/sea'; const moduleHooks = options.moduleHooks !== false; - const vfs = new VirtualFileSystem({ moduleHooks }); - - // Get all asset keys and populate VFS - const keys = getAssetKeys(); - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - // Get asset content as ArrayBuffer and convert to Buffer - const content = getAsset(key); - const buffer = Buffer.from(content); - - // Determine the path - if key starts with /, use as-is, otherwise prepend / - const path = StringPrototypeStartsWith(key, '/') ? key : `/${key}`; - vfs.addFile(path, buffer); - } + const provider = new SEAProvider(); + const vfs = new VirtualFileSystem(provider, { moduleHooks }); // Mount at the specified prefix vfs.mount(prefix); @@ -83,7 +59,8 @@ function hasSeaAssets() { if (!isSea()) { return false; } - const keys = getAssetKeys(); + const { getAssetKeys } = internalBinding('sea'); + const keys = getAssetKeys() || []; return keys.length > 0; } diff --git a/lib/internal/vfs/streams.js b/lib/internal/vfs/streams.js index 71f6c234c6b31d..a07fd04bceb92a 100644 --- a/lib/internal/vfs/streams.js +++ b/lib/internal/vfs/streams.js @@ -83,7 +83,8 @@ class VirtualReadStream extends Readable { this.destroy(createEBADF('read')); return; } - this._content = vfd.getContentSync(); + // Use the file handle's readFileSync to get content + this._content = vfd.entry.readFileSync(); } catch (err) { this.destroy(err); return; diff --git a/lib/vfs.js b/lib/vfs.js new file mode 100644 index 00000000000000..4110fc983461f9 --- /dev/null +++ b/lib/vfs.js @@ -0,0 +1,78 @@ +'use strict'; + +const { VirtualFileSystem } = require('internal/vfs/file_system'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { MemoryProvider } = require('internal/vfs/providers/memory'); + +// SEAProvider is lazy-loaded to avoid loading SEA bindings when not needed +let SEAProvider = null; + +function getSEAProvider() { + if (SEAProvider === null) { + try { + SEAProvider = require('internal/vfs/providers/sea').SEAProvider; + } catch { + // SEA bindings not available (not running in SEA) + SEAProvider = class SEAProviderUnavailable { + constructor() { + throw new Error('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); +} + +/** + * Creates a VirtualFileSystem with SEA assets mounted. + * Only works when running as a Single Executable Application. + * @param {object} [options] Configuration options + * @param {string} [options.mountPoint] Mount point path (default: '/sea') + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks (default: true) + * @param {boolean} [options.virtualCwd] Whether to enable virtual working directory + * @returns {VirtualFileSystem|null} The VFS instance, or null if not running as SEA + */ +function createSEA(options = {}) { + const SEAProviderClass = getSEAProvider(); + try { + const provider = new SEAProviderClass(); + const vfs = new VirtualFileSystem(provider, { + moduleHooks: options.moduleHooks, + virtualCwd: options.virtualCwd, + }); + vfs.mount(options.mountPoint ?? '/sea'); + return vfs; + } catch { + return null; + } +} + +module.exports = { + create, + createSEA, + VirtualFileSystem, + VirtualProvider, + MemoryProvider, + get SEAProvider() { + return getSEAProvider(); + }, +}; From d2074414465bd0edc1575283cec0cdf00822135c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 27 Jan 2026 09:32:12 +0100 Subject: [PATCH 07/19] vfs: remove backward compat methods, use standard fs API Remove backward compatibility methods (addFile, addDirectory, addSymlink, has, remove) from VirtualFileSystem. Users should now use the standard fs-like API (writeFileSync, mkdirSync, symlinkSync, existsSync, unlinkSync). For dynamic content and lazy directories, use the provider methods: - provider.setContentProvider(path, fn) for dynamic file content - provider.setPopulateCallback(path, fn) for lazy directory population Also adds: - MemoryProvider.setReadOnly() to make provider immutable after setup - Fix router.js to handle root mount point (/) correctly --- doc/api/vfs.md | 251 ++++++++++++++----------- lib/internal/vfs/file_system.js | 174 +---------------- lib/internal/vfs/providers/memory.js | 13 +- lib/internal/vfs/router.js | 12 ++ test/parallel/test-vfs-basic.js | 69 +++---- test/parallel/test-vfs-chdir-worker.js | 9 +- test/parallel/test-vfs-chdir.js | 41 ++-- test/parallel/test-vfs-fd.js | 28 +-- test/parallel/test-vfs-glob.js | 69 +++---- test/parallel/test-vfs-import.mjs | 42 ++--- test/parallel/test-vfs-promises.js | 50 ++--- test/parallel/test-vfs-require.js | 50 ++--- test/parallel/test-vfs-streams.js | 24 +-- test/parallel/test-vfs-symlinks.js | 97 +++++----- 14 files changed, 377 insertions(+), 552 deletions(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 5e9106abd071b1..332dcc369341f3 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -181,6 +181,29 @@ added: v26.0.0 Creates a new `VirtualFileSystem` instance. +### `vfs.provider` + + + +* {VirtualProvider} + +The underlying provider for this VFS instance. Can be used to access +provider-specific methods like `setContentProvider()` and `setPopulateCallback()` +for `MemoryProvider`. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Access the provider for advanced features +myVfs.provider.setContentProvider('/dynamic.txt', () => { + return `Time: ${Date.now()}`; +}); +``` + ### `vfs.mount(prefix)` -* Returns: {boolean} +* {boolean} Returns `true` if the VFS is currently mounted. @@ -233,6 +256,16 @@ added: v26.0.0 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 file path. -* `content` {string | Buffer | Function} The file content or a function that - returns content. -* `options` {Object} Optional configuration. - -Adds a file to the VFS. If `content` is a function, it will be called each time -the file is read (dynamic content). - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Static content -myVfs.addFile('/static.txt', 'Static content'); - -// Dynamic content - function is called on each read -let counter = 0; -myVfs.addFile('/counter.txt', () => { - counter++; - return `Count: ${counter}`; -}); - -myVfs.mount('/v'); -const fs = require('node:fs'); -console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 1 -console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 2 -``` - -#### `vfs.addDirectory(path[, populate][, options])` - - - -* `path` {string} The directory path. -* `populate` {Function} Optional callback to lazily populate the directory. -* `options` {Object} Optional configuration. - -Adds a directory to the VFS. If `populate` is provided, it will be called -lazily when the directory is first accessed. - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Lazy directory - populated on first access -myVfs.addDirectory('/lazy', (dir) => { - dir.addFile('generated.txt', 'Generated on demand'); - dir.addDirectory('subdir', (subdir) => { - subdir.addFile('nested.txt', 'Nested content'); - }); -}); - -myVfs.mount('/v'); -const fs = require('node:fs'); - -// Directory is populated when first accessed -console.log(fs.readdirSync('/v/lazy')); // ['generated.txt', 'subdir'] -``` - -#### `vfs.addSymlink(path, target[, options])` - - - -* `path` {string} The symlink path. -* `target` {string} The symlink target (can be relative or absolute). -* `options` {Object} Optional configuration. - -Adds a symbolic link to the VFS. - -#### `vfs.has(path)` - - - -* `path` {string} The path to check. -* Returns: {boolean} - -Returns `true` if the path exists in the VFS. - -#### `vfs.remove(path)` - - - -* `path` {string} The path to remove. - -Removes a file or directory from the VFS. - ## Class: `VirtualProvider` + +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'); +``` + +### `memoryProvider.setContentProvider(path, provider)` + + + +* `path` {string} The file path. +* `provider` {Function} A function that returns the file content. + +Sets a dynamic content provider for a file. The provider function will be +called each time the file is read, allowing for dynamic content generation. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Dynamic content - function is called on each read +let counter = 0; +myVfs.provider.setContentProvider('/counter.txt', () => { + counter++; + return `Count: ${counter}`; +}); + +myVfs.mount('/v'); +const fs = require('node:fs'); +console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 1 +console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 2 +``` + +The provider function can also be async: + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +myVfs.provider.setContentProvider('/async-data.txt', async () => { + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 100)); + return 'Async content'; +}); + +// Use promises API for async content providers +const content = await myVfs.promises.readFile('/async-data.txt', 'utf8'); +``` + +### `memoryProvider.setPopulateCallback(path, callback)` + + + +* `path` {string} The directory path. +* `callback` {Function} A function that populates the directory contents. + +Sets a lazy populate callback for a directory. The callback will be called +the first time the directory is accessed (e.g., via `readdirSync` or when +accessing a child path). + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Lazy directory - populated on first access +myVfs.provider.setPopulateCallback('/lazy', (dir) => { + dir.addFile('generated.txt', 'Generated on demand'); + dir.addDirectory('subdir', (subdir) => { + subdir.addFile('nested.txt', 'Nested content'); + }); +}); + +myVfs.mount('/v'); +const fs = require('node:fs'); + +// Directory is populated when first accessed +console.log(fs.readdirSync('/v/lazy')); // ['generated.txt', 'subdir'] +``` + +The callback receives a scoped VFS object with `addFile()`, `addDirectory()`, +and `addSymlink()` methods for populating the directory. + ## Class: `SEAProvider` - -* `path` {string} The file path. -* `provider` {Function} A function that returns the file content. - -Sets a dynamic content provider for a file. The provider function will be -called each time the file is read, allowing for dynamic content generation. - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Dynamic content - function is called on each read -let counter = 0; -myVfs.provider.setContentProvider('/counter.txt', () => { - counter++; - return `Count: ${counter}`; -}); - -myVfs.mount('/v'); -const fs = require('node:fs'); -console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 1 -console.log(fs.readFileSync('/v/counter.txt', 'utf8')); // Count: 2 -``` - -The provider function can also be async: - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -myVfs.provider.setContentProvider('/async-data.txt', async () => { - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 100)); - return 'Async content'; -}); - -// Use promises API for async content providers -const content = await myVfs.promises.readFile('/async-data.txt', 'utf8'); -``` - -### `memoryProvider.setPopulateCallback(path, callback)` - - - -* `path` {string} The directory path. -* `callback` {Function} A function that populates the directory contents. - -Sets a lazy populate callback for a directory. The callback will be called -the first time the directory is accessed (e.g., via `readdirSync` or when -accessing a child path). - -```cjs -const vfs = require('node:vfs'); - -const myVfs = vfs.create(); - -// Lazy directory - populated on first access -myVfs.provider.setPopulateCallback('/lazy', (dir) => { - dir.addFile('generated.txt', 'Generated on demand'); - dir.addDirectory('subdir', (subdir) => { - subdir.addFile('nested.txt', 'Nested content'); - }); -}); - -myVfs.mount('/v'); -const fs = require('node:fs'); - -// Directory is populated when first accessed -console.log(fs.readdirSync('/v/lazy')); // ['generated.txt', 'subdir'] -``` - -The callback receives a scoped VFS object with `addFile()`, `addDirectory()`, -and `addSymlink()` methods for populating the directory. - ## Class: `SEAProvider` - -* `options` {Object} - * `mountPoint` {string} The path prefix where SEA assets will be mounted. - **Default:** `'/sea'`. - * `moduleHooks` {boolean} Whether to enable module loading hooks. - **Default:** `true`. - * `virtualCwd` {boolean} Whether to enable virtual working directory. - **Default:** `false`. -* Returns: {VirtualFileSystem | null} Returns `null` if not running as a - Single Executable Application. - -Creates a `VirtualFileSystem` pre-configured with SEA (Single Executable -Application) assets. This is a convenience method for accessing bundled assets -in SEA builds. - -```cjs -const vfs = require('node:vfs'); -const fs = require('node:fs'); - -const seaVfs = vfs.createSEA({ mountPoint: '/assets' }); -if (seaVfs) { - // Running as SEA - assets are available - const data = fs.readFileSync('/assets/config.json', 'utf8'); -} -``` - ## Class: `VirtualFileSystem` Unmounts the virtual file system. After unmounting, virtual files are no longer -accessible through the `fs` module. +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.isMounted` @@ -244,7 +258,14 @@ added: v26.0.0 * `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. +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. Since `process.chdir()` +is not available in Worker threads, virtual cwd should only be used in the main +thread. ### `vfs.cwd()` @@ -252,9 +273,12 @@ the VFS when `virtualCwd` is enabled. added: v26.0.0 --> -* Returns: {string} +* Returns: {string|null} + +Returns the current virtual working directory, or `null` if no virtual directory +has been set yet. -Returns the current virtual working directory. +Throws `ERR_INVALID_STATE` if `virtualCwd` was not enabled during construction. ### File System Methods @@ -289,6 +313,16 @@ All paths are relative to the VFS root (not the mount point). 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'); @@ -427,6 +461,23 @@ try { 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'); @@ -477,6 +528,25 @@ 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 From b82cc48448467e5246a4aee3d50f62ab32fb9ca9 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 28 Jan 2026 23:24:41 +0100 Subject: [PATCH 14/19] doc: clarify virtualCwd behavior in Worker threads Virtual cwd works for virtual paths in Workers, but process.chdir() to real filesystem paths will throw ERR_WORKER_UNSUPPORTED_OPERATION. Co-Authored-By: Claude Opus 4.5 --- doc/api/vfs.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 1362175619e73a..6a9f8ebd6d9b42 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -263,9 +263,9 @@ 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. Since `process.chdir()` -is not available in Worker threads, virtual cwd should only be used in the main -thread. +`process.cwd()` to support virtual paths transparently. In Worker threads, +`process.chdir()` to virtual paths will work, but attempting to change to real +filesystem paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. ### `vfs.cwd()` From ad601c8ee6ee5ef1400b3bf2b16a6b4ddd1f204e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 00:03:32 +0100 Subject: [PATCH 15/19] vfs: add RealFSProvider for mounting real directories RealFSProvider wraps a real filesystem 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 - Creating sandboxed views of real directories The provider prevents path traversal attacks by ensuring resolved paths stay within the configured root directory. Co-Authored-By: Claude Opus 4.5 --- doc/api/vfs.md | 87 +++++- lib/internal/vfs/providers/real.js | 376 ++++++++++++++++++++++++ lib/vfs.js | 2 + test/parallel/test-vfs-real-provider.js | 234 +++++++++++++++ 4 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 lib/internal/vfs/providers/real.js create mode 100644 test/parallel/test-vfs-real-provider.js diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 6a9f8ebd6d9b42..86c0ff85393796 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -265,7 +265,7 @@ 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 -filesystem paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. +file system paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. ### `vfs.cwd()` @@ -456,6 +456,91 @@ try { } ``` +## 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 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/vfs.js b/lib/vfs.js index 69160256b106b4..b9c7b133fc865f 100644 --- a/lib/vfs.js +++ b/lib/vfs.js @@ -8,6 +8,7 @@ const { 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 = null; @@ -52,6 +53,7 @@ module.exports = { VirtualFileSystem, VirtualProvider, MemoryProvider, + RealFSProvider, get SEAProvider() { return getSEAProvider(); }, 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')); +} From b9ab34809d0339ac7c527d2575c256ba6b0b49bd Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 00:09:46 +0100 Subject: [PATCH 16/19] tools: add VFS types to doc type-parser Add VirtualFileSystem, VirtualProvider, MemoryProvider, SEAProvider, and RealFSProvider to the type-parser for documentation generation. --- tools/doc/type-parser.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index de913c98534a09..75ce7cfbb5ff51 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -165,7 +165,12 @@ const customTypesMap = { 'fs.StatFs': 'fs.html#class-fsstatfs', 'fs.StatWatcher': 'fs.html#class-fsstatwatcher', 'fs.WriteStream': 'fs.html#class-fswritestream', - 'VirtualFileSystem': 'fs.html#class-virtualfilesystem', + + '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', From d930079dc42e2f7d25633d4884a166bb98916c5b Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 08:13:00 +0100 Subject: [PATCH 17/19] doc: use REPLACEME for version placeholders in vfs.md --- doc/api/vfs.md | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 86c0ff85393796..4a8c8f79674340 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -1,9 +1,9 @@ # Virtual File System - + > Stability: 1 - Experimental @@ -99,7 +99,7 @@ myVfs.unmount(); ## `vfs.create([provider][, options])` * `provider` {VirtualProvider} Optional provider instance. Defaults to a new @@ -143,7 +143,7 @@ const vfsWithOptions = vfs.create({ moduleHooks: false }); ## Class: `VirtualFileSystem` The `VirtualFileSystem` class provides a file system interface backed by a @@ -153,7 +153,7 @@ make virtual files accessible through the `fs` module. ### `new VirtualFileSystem([provider][, options])` * `provider` {VirtualProvider} The provider to use. **Default:** `MemoryProvider`. @@ -166,7 +166,7 @@ Creates a new `VirtualFileSystem` instance. ### `vfs.provider` * {VirtualProvider} @@ -188,7 +188,7 @@ console.log(myVfs.provider.readonly); // true ### `vfs.mount(prefix)` * `prefix` {string} The path prefix where the VFS will be mounted. @@ -211,7 +211,7 @@ require('node:fs').readFileSync('/virtual/data.txt', 'utf8'); // 'Hello' ### `vfs.unmount()` Unmounts the virtual file system. After unmounting, virtual files are no longer @@ -222,7 +222,7 @@ working directory if one was set. ### `vfs.isMounted` * {boolean} @@ -232,7 +232,7 @@ Returns `true` if the VFS is currently mounted. ### `vfs.mountPoint` * {string | null} @@ -242,7 +242,7 @@ The current mount point, or `null` if not mounted. ### `vfs.readonly` * {boolean} @@ -252,7 +252,7 @@ Returns `true` if the underlying provider is read-only. ### `vfs.chdir(path)` * `path` {string} The new working directory path within the VFS. @@ -270,7 +270,7 @@ file system paths will throw `ERR_WORKER_UNSUPPORTED_OPERATION`. ### `vfs.cwd()` * Returns: {string|null} @@ -338,7 +338,7 @@ async function example() { ## Class: `VirtualProvider` The `VirtualProvider` class is an abstract base class for VFS providers. @@ -349,7 +349,7 @@ Providers implement the actual file system storage and operations. #### `provider.readonly` * {boolean} @@ -359,7 +359,7 @@ Returns `true` if the provider is read-only. #### `provider.supportsSymlinks` * {boolean} @@ -397,7 +397,7 @@ class MyProvider extends VirtualProvider { ## Class: `MemoryProvider` The `MemoryProvider` stores files in memory. It supports full read/write @@ -412,7 +412,7 @@ const myVfs = create(new MemoryProvider()); ### `memoryProvider.setReadOnly()` Sets the provider to read-only mode. Once set to read-only, the provider @@ -438,7 +438,7 @@ myVfs.provider.setReadOnly(); ## Class: `SEAProvider` The `SEAProvider` provides read-only access to assets bundled in a Single @@ -459,7 +459,7 @@ try { ## Class: `RealFSProvider` The `RealFSProvider` wraps a real file system directory, allowing it to be @@ -473,7 +473,7 @@ mounted at a different VFS path. This is useful for: ### `new RealFSProvider(rootPath)` * `rootPath` {string} The real file system path to use as the provider root. @@ -534,7 +534,7 @@ if (isMainThread) { ### `realFSProvider.rootPath` * {string} From 3388e9d821c5b4faf1cbc3fc90355a07b6e51f56 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 10:57:47 +0100 Subject: [PATCH 18/19] doc: add security warnings and symlink documentation to vfs.md - Add security considerations section warning about path shadowing risks - Document that VFS shadows real paths when mounted - Add symlink documentation explaining VFS-internal-only behavior - Clarify that only mount mode exists (no overlay mode) - Reorder synchronous methods alphabetically per doc conventions Addresses review comments from @jasnell regarding security documentation, overlay mode clarification, alphabetical ordering, and symlink behavior. --- doc/api/vfs.md | 111 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 13 deletions(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 4a8c8f79674340..6bb1501ca5bc92 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -37,6 +37,13 @@ is useful for: * 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, @@ -197,6 +204,12 @@ 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'); @@ -287,26 +300,26 @@ All paths are relative to the VFS root (not the mount point). #### Synchronous Methods -* `vfs.readFileSync(path[, options])` - Read a file -* `vfs.writeFileSync(path, data[, options])` - Write a file +* `vfs.accessSync(path[, mode])` - Check file accessibility * `vfs.appendFileSync(path, data[, options])` - Append to a file -* `vfs.statSync(path[, options])` - Get file stats -* `vfs.lstatSync(path[, options])` - Get file stats (no symlink follow) -* `vfs.readdirSync(path[, options])` - Read directory contents -* `vfs.mkdirSync(path[, options])` - Create a directory -* `vfs.rmdirSync(path)` - Remove a directory -* `vfs.unlinkSync(path)` - Remove a file -* `vfs.renameSync(oldPath, newPath)` - Rename a file or directory +* `vfs.closeSync(fd)` - Close a file descriptor * `vfs.copyFileSync(src, dest[, mode])` - Copy a file * `vfs.existsSync(path)` - Check if path exists -* `vfs.accessSync(path[, mode])` - Check file accessibility +* `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.closeSync(fd)` - Close a file descriptor +* `vfs.readFileSync(path[, options])` - Read a file * `vfs.readSync(fd, buffer, offset, length, position)` - Read from fd -* `vfs.writeSync(fd, buffer, offset, length, position)` - Write to fd -* `vfs.realpathSync(path[, options])` - Resolve symlinks * `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 @@ -649,4 +662,76 @@ 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' +``` + +### 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 From 22c38426dd839919dd2f5ebf86d383bd03dd0be8 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 29 Jan 2026 11:53:20 +0100 Subject: [PATCH 19/19] vfs: address code review feedback from @jasnell - Use undefined instead of null for lazy-loaded SEAProvider - Add validateBoolean for moduleHooks and virtualCwd options - Use template literal for path concatenation - Convert VirtualReadStream to use private class fields - Cache DateNow() result in MemoryEntry constructor Addresses review comments #18, #19, #21, #23, #24, #29. --- lib/internal/vfs/file_system.js | 11 ++++- lib/internal/vfs/providers/memory.js | 7 +-- lib/internal/vfs/streams.js | 73 ++++++++++++++-------------- lib/vfs.js | 4 +- 4 files changed, 53 insertions(+), 42 deletions(-) diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index 0c68545f1b6eac..ca72ce5857f9e9 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -10,6 +10,7 @@ const { ERR_INVALID_STATE, }, } = require('internal/errors'); +const { validateBoolean } = require('internal/validators'); const { MemoryProvider } = require('internal/vfs/providers/memory'); const { normalizePath, @@ -81,6 +82,14 @@ class VirtualFileSystem { } } + // Validate boolean options + if (options.moduleHooks !== undefined) { + validateBoolean(options.moduleHooks, 'options.moduleHooks'); + } + if (options.virtualCwd !== undefined) { + validateBoolean(options.virtualCwd, 'options.virtualCwd'); + } + this[kProvider] = provider ?? new MemoryProvider(); this[kMountPoint] = null; this[kMounted] = false; @@ -181,7 +190,7 @@ class VirtualFileSystem { // If virtual cwd is enabled and set, resolve relative to it if (this[kVirtualCwdEnabled] && this[kVirtualCwd] !== null) { - const resolved = this[kVirtualCwd] + '/' + inputPath; + const resolved = `${this[kVirtualCwd]}/${inputPath}`; return normalizePath(resolved); } diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js index 890afaa97185bd..b12944b4bedb24 100644 --- a/lib/internal/vfs/providers/memory.js +++ b/lib/internal/vfs/providers/memory.js @@ -63,9 +63,10 @@ class MemoryEntry { this.children = null; // For directories this.populate = null; // For directories - lazy population callback this.populated = true; // For directories - has populate been called? - this.mtime = DateNow(); - this.ctime = DateNow(); - this.birthtime = DateNow(); + const now = DateNow(); + this.mtime = now; + this.ctime = now; + this.birthtime = now; } /** diff --git a/lib/internal/vfs/streams.js b/lib/internal/vfs/streams.js index a07fd04bceb92a..06aafb9e7ad98a 100644 --- a/lib/internal/vfs/streams.js +++ b/lib/internal/vfs/streams.js @@ -11,6 +11,15 @@ 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 @@ -27,18 +36,14 @@ class VirtualReadStream extends Readable { super({ ...streamOptions, highWaterMark, encoding }); - this._vfs = vfs; - this._path = filePath; - this._fd = null; - this._start = start; - this._end = end; - this._pos = start; - this._content = null; - this._destroyed = false; - this._autoClose = options.autoClose !== false; + 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()); + process.nextTick(() => this.#openFile()); } /** @@ -46,19 +51,18 @@ class VirtualReadStream extends Readable { * @returns {string} */ get path() { - return this._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. - * @private */ - _openFile() { + #openFile() { try { - this._fd = this._vfs.openSync(this._path); - this.emit('open', this._fd); + this.#fd = this.#vfs.openSync(this.#path); + this.emit('open', this.#fd); this.emit('ready'); } catch (err) { this.destroy(err); @@ -68,23 +72,22 @@ class VirtualReadStream extends Readable { /** * Implements the readable _read method. * @param {number} size Number of bytes to read - * @private */ _read(size) { - if (this._destroyed || this._fd === null) { + if (this.#destroyed || this.#fd === null) { return; } // Load content on first read (lazy loading) - if (this._content === null) { + if (this.#content === null) { try { - const vfd = require('internal/vfs/fd').getVirtualFd(this._fd); + 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(); + this.#content = vfd.entry.readFileSync(); } catch (err) { this.destroy(err); return; @@ -93,40 +96,39 @@ class VirtualReadStream extends Readable { // 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; + 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 + // 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; + 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) { + if (this.#pos >= endPos || this.#pos >= this.#content.length) { this.push(null); - // Note: _close() will be called by _destroy() when autoClose is true + // 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. - * @private */ - _close() { - if (this._fd !== null) { + #close() { + if (this.#fd !== null) { try { - this._vfs.closeSync(this._fd); + this.#vfs.closeSync(this.#fd); } catch { // Ignore close errors } - this._fd = null; + this.#fd = null; } } @@ -134,12 +136,11 @@ class VirtualReadStream extends Readable { * Implements the readable _destroy method. * @param {Error|null} err The error * @param {Function} callback Callback - * @private */ _destroy(err, callback) { - this._destroyed = true; - if (this._autoClose) { - this._close(); + this.#destroyed = true; + if (this.#autoClose) { + this.#close(); } callback(err); } diff --git a/lib/vfs.js b/lib/vfs.js index b9c7b133fc865f..6def631e112d36 100644 --- a/lib/vfs.js +++ b/lib/vfs.js @@ -11,10 +11,10 @@ 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 = null; +let SEAProvider; function getSEAProvider() { - if (SEAProvider === null) { + if (SEAProvider === undefined) { try { SEAProvider = require('internal/vfs/providers/sea').SEAProvider; } catch {