diff --git a/doc/api/index.md b/doc/api/index.md index 8db8b8c88062..d7a04fcea1ef 100644 --- a/doc/api/index.md +++ b/doc/api/index.md @@ -66,6 +66,7 @@ * [URL](url.md) * [Utilities](util.md) * [V8](v8.md) +* [Virtual File System](vfs.md) * [VM](vm.md) * [WASI](wasi.md) * [Web Crypto API](webcrypto.md) diff --git a/doc/api/vfs.md b/doc/api/vfs.md new file mode 100644 index 000000000000..5063b3824748 --- /dev/null +++ b/doc/api/vfs.md @@ -0,0 +1,497 @@ +# Virtual File System + + + + + +> Stability: 1 - Experimental + + + +The `node:vfs` module provides a virtual file system that can be mounted +alongside the real file system. Virtual files can be read using standard `fs` +operations and loaded as modules using `require()` or `import`. + +To access it: + +```mjs +import vfs from 'node:vfs'; +``` + +```cjs +const vfs = require('node:vfs'); +``` + +This module is only available under the `node:` scheme. + +## Overview + +The Virtual File System (VFS) allows you to create in-memory file systems that +integrate seamlessly with the Node.js `fs` module and module loading system. This +is useful for: + +* Bundling assets in Single Executable Applications (SEA) +* Testing file system operations without touching the disk +* Creating virtual module systems +* Embedding configuration or data files in applications + +## 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 }); +``` + +## Class: `VirtualFileSystem` + + + +The `VirtualFileSystem` class provides a file system interface backed by a +provider. It supports standard file system operations and can be mounted to +make virtual files accessible through the `fs` module. + +### `new VirtualFileSystem([provider][, options])` + + + +* `provider` {VirtualProvider} The provider to use. **Default:** `MemoryProvider`. +* `options` {Object} + * `moduleHooks` {boolean} Enable module loading hooks. **Default:** `true`. + * `virtualCwd` {boolean} Enable virtual working directory. **Default:** `false`. + +Creates a new `VirtualFileSystem` instance. + +### `vfs.provider` + + + +* {VirtualProvider} + +The underlying provider for this VFS instance. Can be used to access +provider-specific methods like `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)` + + + +* `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` + + + +* {boolean} + +Returns `true` if the VFS is currently mounted. + +### `vfs.mountPoint` + + + +* {string | null} + +The current mount point, or `null` if not mounted. + +### `vfs.readonly` + + + +* {boolean} + +Returns `true` if the underlying provider is read-only. + +### `vfs.chdir(path)` + + + +* `path` {string} The new working directory path within the VFS. + +Changes the virtual working directory. This only affects path resolution within +the VFS when `virtualCwd` is enabled. + +### `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' +} +``` + +## Class: `VirtualProvider` + + + +The `VirtualProvider` class is an abstract base class for VFS providers. +Providers implement the actual file system storage and operations. + +### Properties + +#### `provider.readonly` + + + +* {boolean} + +Returns `true` if the provider is read-only. + +#### `provider.supportsSymlinks` + + + +* {boolean} + +Returns `true` if the provider supports symbolic links. + +### Creating Custom Providers + +To create a custom provider, extend `VirtualProvider` and implement the +required methods: + +```cjs +const { VirtualProvider } = require('node:vfs'); + +class MyProvider extends VirtualProvider { + get readonly() { return false; } + get supportsSymlinks() { return true; } + + openSync(path, flags, mode) { + // Implementation + } + + statSync(path, options) { + // Implementation + } + + readdirSync(path, options) { + // Implementation + } + + // ... implement other required methods +} +``` + +## Class: `MemoryProvider` + + + +The `MemoryProvider` stores files in memory. It supports full read/write +operations and symbolic links. + +```cjs +const { create, MemoryProvider } = require('node:vfs'); + +const myVfs = create(new MemoryProvider()); +``` + +### `memoryProvider.setReadOnly()` + + + +Sets the provider to read-only mode. Once set to read-only, the provider +cannot be changed back to writable. This is useful for finalizing a VFS +after initial population. + +```cjs +const vfs = require('node:vfs'); + +const myVfs = vfs.create(); + +// Populate the VFS +myVfs.mkdirSync('/app'); +myVfs.writeFileSync('/app/config.json', '{"readonly": true}'); + +// Make it read-only +myVfs.provider.setReadOnly(); + +// This would now throw an error +// myVfs.writeFileSync('/app/config.json', 'new content'); +``` + +## Class: `SEAProvider` + + + +The `SEAProvider` provides read-only access to assets bundled in a Single +Executable Application (SEA). It can only be used when running as a SEA. + +```cjs +const { create, SEAProvider } = require('node:vfs'); + +// Only works in SEA builds +try { + const seaVfs = create(new SEAProvider()); + seaVfs.mount('/assets'); +} catch (err) { + console.log('Not running as SEA'); +} +``` + +## 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 + +When running as a Single Executable Application (SEA), bundled assets are +automatically mounted at `/sea`. No additional setup is required: + +```cjs +// In your SEA entry script +const fs = require('node:fs'); + +// Access bundled assets directly - they are automatically available at /sea +const config = JSON.parse(fs.readFileSync('/sea/config.json', 'utf8')); +const template = fs.readFileSync('/sea/templates/index.html', 'utf8'); +``` + +See the [Single Executable Applications][] documentation for more information +on creating SEA builds with assets. + +[Single Executable Applications]: single-executable-applications.md diff --git a/lib/fs.js b/lib/fs.js index 3e2d6e8ce7c0..8e7bf9804a70 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 f49f0814bbc6..6e25416d6db5 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/errors.js b/lib/internal/vfs/errors.js index 0cb681c6eb61..8fd0d7cf6439 100644 --- a/lib/internal/vfs/errors.js +++ b/lib/internal/vfs/errors.js @@ -11,6 +11,7 @@ const { const { UV_ENOENT, UV_ENOTDIR, + UV_ENOTEMPTY, UV_EISDIR, UV_EBADF, UV_EEXIST, @@ -51,6 +52,22 @@ function createENOTDIR(syscall, path) { return err; } +/** + * Creates an ENOTEMPTY error for non-empty directory. + * @param {string} syscall The system call name + * @param {string} path The path of the non-empty directory + * @returns {Error} + */ +function createENOTEMPTY(syscall, path) { + const err = new UVException({ + errno: UV_ENOTEMPTY, + syscall, + path, + }); + ErrorCaptureStackTrace(err, createENOTEMPTY); + return err; +} + /** * Creates an EISDIR error. * @param {string} syscall The system call name @@ -148,6 +165,7 @@ function createELOOP(syscall, path) { module.exports = { createENOENT, createENOTDIR, + createENOTEMPTY, createEISDIR, createEBADF, createEEXIST, diff --git a/lib/internal/vfs/fd.js b/lib/internal/vfs/fd.js index 93975ad0fc91..3bc581141645 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 000000000000..6a8bac5a6bd7 --- /dev/null +++ b/lib/internal/vfs/file_handle.js @@ -0,0 +1,533 @@ +'use strict'; + +const { + MathMin, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { + codes: { + ERR_INVALID_STATE, + ERR_METHOD_NOT_IMPLEMENTED, + }, +} = require('internal/errors'); +const { + createEBADF, +} = require('internal/vfs/errors'); + +// Private symbols +const kPath = Symbol('kPath'); +const kFlags = Symbol('kFlags'); +const kMode = Symbol('kMode'); +const kPosition = Symbol('kPosition'); +const kClosed = Symbol('kClosed'); + +/** + * Base class for virtual file handles. + * Provides the interface that file handles must implement. + */ +class VirtualFileHandle { + /** + * @param {string} path The file path + * @param {string} flags The open flags + * @param {number} [mode] The file mode + */ + constructor(path, flags, mode) { + this[kPath] = path; + this[kFlags] = flags; + this[kMode] = mode ?? 0o644; + this[kPosition] = 0; + this[kClosed] = false; + } + + /** + * Gets the file path. + * @returns {string} + */ + get path() { + return this[kPath]; + } + + /** + * Gets the open flags. + * @returns {string} + */ + get flags() { + return this[kFlags]; + } + + /** + * Gets the file mode. + * @returns {number} + */ + get mode() { + return this[kMode]; + } + + /** + * Gets the current position. + * @returns {number} + */ + get position() { + return this[kPosition]; + } + + /** + * Sets the current position. + * @param {number} pos The new position + */ + set position(pos) { + this[kPosition] = pos; + } + + /** + * Returns true if the handle is closed. + * @returns {boolean} + */ + get closed() { + return this[kClosed]; + } + + /** + * Throws if the handle is closed. + * @private + */ + _checkClosed() { + if (this[kClosed]) { + throw createEBADF('read'); + } + } + + /** + * Reads data from the file. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {Promise<{ bytesRead: number, buffer: Buffer }>} + */ + async read(buffer, offset, length, position) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('read'); + } + + /** + * Reads data from the file synchronously. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + readSync(buffer, offset, length, position) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('readSync'); + } + + /** + * Writes data to the file. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {Promise<{ bytesWritten: number, buffer: Buffer }>} + */ + async write(buffer, offset, length, position) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('write'); + } + + /** + * Writes data to the file synchronously. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + writeSync(buffer, offset, length, position) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('writeSync'); + } + + /** + * Reads the entire file. + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(options) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('readFile'); + } + + /** + * Reads the entire file synchronously. + * @param {object|string} [options] Options or encoding + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + readFileSync(options) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('readFileSync'); + } + + /** + * Writes data to the file (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + * @returns {Promise} + */ + async writeFile(data, options) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('writeFile'); + } + + /** + * Writes data to the file synchronously (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(data, options) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('writeFileSync'); + } + + /** + * Gets file stats. + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(options) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('stat'); + } + + /** + * Gets file stats synchronously. + * @param {object} [options] Options + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + statSync(options) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('statSync'); + } + + /** + * Truncates the file. + * @param {number} [len] The new length + * @returns {Promise} + */ + async truncate(len) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('truncate'); + } + + /** + * Truncates the file synchronously. + * @param {number} [len] The new length + */ + truncateSync(len) { + this._checkClosed(); + throw new ERR_METHOD_NOT_IMPLEMENTED('truncateSync'); + } + + /** + * Closes the file handle. + * @returns {Promise} + */ + async close() { + this[kClosed] = true; + } + + /** + * Closes the file handle synchronously. + */ + closeSync() { + this[kClosed] = true; + } +} + +/** + * A file handle for in-memory file content. + * Used by MemoryProvider and similar providers. + */ +class MemoryFileHandle extends VirtualFileHandle { + #content; + #entry; + #getStats; + + /** + * @param {string} path The file path + * @param {string} flags The open flags + * @param {number} [mode] The file mode + * @param {Buffer} content The initial file content + * @param {object} entry The entry object (for updating content) + * @param {Function} getStats Function to get updated stats + */ + constructor(path, flags, mode, content, entry, getStats) { + super(path, flags, mode); + this.#content = content; + this.#entry = entry; + this.#getStats = getStats; + + // Handle different open modes + if (flags === 'w' || flags === 'w+') { + // Write mode: truncate + this.#content = Buffer.alloc(0); + if (entry) { + entry.content = this.#content; + } + } else if (flags === 'a' || flags === 'a+') { + // Append mode: position at end + this.position = this.#content.length; + } + } + + /** + * Gets the current content synchronously. + * For dynamic content providers, this gets fresh content from the entry. + * @returns {Buffer} + */ + get content() { + // If entry has a dynamic content provider, get fresh content sync + if (this.#entry?.isDynamic && this.#entry.isDynamic()) { + return this.#entry.getContentSync(); + } + return this.#content; + } + + /** + * Gets the current content asynchronously. + * For dynamic content providers, this gets fresh content from the entry. + * @returns {Promise} + */ + async getContentAsync() { + // If entry has a dynamic content provider, get fresh content async + if (this.#entry?.getContentAsync) { + return this.#entry.getContentAsync(); + } + return this.#content; + } + + /** + * Gets the raw stored content (without dynamic resolution). + * @returns {Buffer} + */ + get _rawContent() { + return this.#content; + } + + /** + * Reads data from the file synchronously. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {number} The number of bytes read + */ + readSync(buffer, offset, length, position) { + this._checkClosed(); + + // Get content (resolves dynamic content providers) + const content = this.content; + const readPos = position !== null && position !== undefined ? position : this.position; + const available = content.length - readPos; + + if (available <= 0) { + return 0; + } + + const bytesToRead = MathMin(length, available); + content.copy(buffer, offset, readPos, readPos + bytesToRead); + + // Update position if not using explicit position + if (position === null || position === undefined) { + this.position = readPos + bytesToRead; + } + + return bytesToRead; + } + + /** + * Reads data from the file. + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer to start writing + * @param {number} length The number of bytes to read + * @param {number|null} position The position to read from (null uses current position) + * @returns {Promise<{ bytesRead: number, buffer: Buffer }>} + */ + async read(buffer, offset, length, position) { + const bytesRead = this.readSync(buffer, offset, length, position); + return { __proto__: null, bytesRead, buffer }; + } + + /** + * Writes data to the file synchronously. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {number} The number of bytes written + */ + writeSync(buffer, offset, length, position) { + this._checkClosed(); + + const writePos = position !== null && position !== undefined ? position : this.position; + const data = buffer.subarray(offset, offset + length); + + // Expand content if needed + if (writePos + length > this.#content.length) { + const newContent = Buffer.alloc(writePos + length); + this.#content.copy(newContent, 0, 0, this.#content.length); + this.#content = newContent; + } + + // Write the data + data.copy(this.#content, writePos); + + // Update the entry's content + if (this.#entry) { + this.#entry.content = this.#content; + } + + // Update position if not using explicit position + if (position === null || position === undefined) { + this.position = writePos + length; + } + + return length; + } + + /** + * Writes data to the file. + * @param {Buffer} buffer The buffer to write from + * @param {number} offset The offset in the buffer to start reading + * @param {number} length The number of bytes to write + * @param {number|null} position The position to write to (null uses current position) + * @returns {Promise<{ bytesWritten: number, buffer: Buffer }>} + */ + async write(buffer, offset, length, position) { + const bytesWritten = this.writeSync(buffer, offset, length, position); + return { __proto__: null, bytesWritten, buffer }; + } + + /** + * Reads the entire file synchronously. + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(options) { + this._checkClosed(); + + // Get content (resolves dynamic content providers) + const content = this.content; + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return Buffer.from(content); + } + + /** + * Reads the entire file. + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(options) { + this._checkClosed(); + + // Get content asynchronously (supports async content providers) + const content = await this.getContentAsync(); + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return Buffer.from(content); + } + + /** + * Writes data to the file synchronously (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(data, options) { + this._checkClosed(); + + const buffer = typeof data === 'string' ? Buffer.from(data, options?.encoding) : data; + this.#content = Buffer.from(buffer); + + // Update the entry's content + if (this.#entry) { + this.#entry.content = this.#content; + } + + this.position = this.#content.length; + } + + /** + * Writes data to the file (replacing content). + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + * @returns {Promise} + */ + async writeFile(data, options) { + this.writeFileSync(data, options); + } + + /** + * Gets file stats synchronously. + * @param {object} [options] Options + * @returns {Stats} + */ + statSync(options) { + this._checkClosed(); + if (this.#getStats) { + return this.#getStats(this.#content.length); + } + throw new ERR_INVALID_STATE('stats not available'); + } + + /** + * Gets file stats. + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(options) { + return this.statSync(options); + } + + /** + * Truncates the file synchronously. + * @param {number} [len] The new length + */ + truncateSync(len = 0) { + this._checkClosed(); + + if (len < this.#content.length) { + this.#content = this.#content.subarray(0, len); + } else if (len > this.#content.length) { + const newContent = Buffer.alloc(len); + this.#content.copy(newContent, 0, 0, this.#content.length); + this.#content = newContent; + } + + // Update the entry's content + if (this.#entry) { + this.#entry.content = this.#content; + } + } + + /** + * Truncates the file. + * @param {number} [len] The new length + * @returns {Promise} + */ + async truncate(len) { + this.truncateSync(len); + } +} + +module.exports = { + VirtualFileHandle, + MemoryFileHandle, +}; diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js new file mode 100644 index 000000000000..0c68545f1b6e --- /dev/null +++ b/lib/internal/vfs/file_system.js @@ -0,0 +1,930 @@ +'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, +} = require('internal/vfs/fd'); +const { + createENOENT, + createENOTDIR, + createEBADF, +} = require('internal/vfs/errors'); +const { createVirtualReadStream } = require('internal/vfs/streams'); +const { emitExperimentalWarning } = require('internal/util'); + +// Private symbols +const kProvider = Symbol('kProvider'); +const kMountPoint = Symbol('kMountPoint'); +const kMounted = Symbol('kMounted'); +const kModuleHooks = Symbol('kModuleHooks'); +const kPromises = Symbol('kPromises'); +const kVirtualCwd = Symbol('kVirtualCwd'); +const kVirtualCwdEnabled = Symbol('kVirtualCwdEnabled'); +const kOriginalChdir = Symbol('kOriginalChdir'); +const kOriginalCwd = Symbol('kOriginalCwd'); + +// Lazy-loaded module hooks +let registerVFS; +let unregisterVFS; + +function loadModuleHooks() { + if (!registerVFS) { + const hooks = require('internal/vfs/module_hooks'); + registerVFS = hooks.registerVFS; + unregisterVFS = hooks.unregisterVFS; + } +} + +/** + * Virtual File System implementation using Provider architecture. + * Wraps a Provider and provides mount point routing and virtual cwd. + */ +class VirtualFileSystem { + /** + * @param {VirtualProvider|object} [providerOrOptions] The provider to use, or options + * @param {object} [options] Configuration options + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks (default: true) + * @param {boolean} [options.virtualCwd] Whether to enable virtual working directory + */ + constructor(providerOrOptions, options = {}) { + emitExperimentalWarning('VirtualFileSystem'); + + // Handle case where first arg is options object (no provider) + let provider = null; + if (providerOrOptions !== undefined && providerOrOptions !== null) { + if (typeof providerOrOptions.openSync === 'function') { + // It's a provider + provider = providerOrOptions; + } else if (typeof providerOrOptions === 'object') { + // It's options (no provider specified) + options = providerOrOptions; + provider = null; + } + } + + this[kProvider] = provider ?? new MemoryProvider(); + this[kMountPoint] = null; + this[kMounted] = 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]; + } + + // ==================== Virtual Working Directory ==================== + + /** + * Gets the virtual current working directory. + * Returns null if no virtual cwd is set. + * @returns {string|null} + */ + cwd() { + if (!this[kVirtualCwdEnabled]) { + throw new ERR_INVALID_STATE('virtual cwd is not enabled'); + } + return this[kVirtualCwd]; + } + + /** + * Sets the virtual current working directory. + * The path must exist in the VFS. + * @param {string} dirPath The directory path to set as cwd + */ + chdir(dirPath) { + if (!this[kVirtualCwdEnabled]) { + throw new ERR_INVALID_STATE('virtual cwd is not enabled'); + } + + const providerPath = this._toProviderPath(dirPath); + const stats = this[kProvider].statSync(providerPath); + + if (!stats.isDirectory()) { + throw createENOTDIR('chdir', dirPath); + } + + // Store the full path (with mount point) as virtual cwd + this[kVirtualCwd] = this._toMountedPath(providerPath); + } + + /** + * Resolves a path relative to the virtual cwd if set. + * If the path is absolute or no virtual cwd is set, returns the path as-is. + * @param {string} inputPath The path to resolve + * @returns {string} The resolved path + */ + resolvePath(inputPath) { + // If path is absolute, return as-is + if (isAbsolutePath(inputPath)) { + return normalizePath(inputPath); + } + + // If virtual cwd is enabled and set, resolve relative to it + if (this[kVirtualCwdEnabled] && this[kVirtualCwd] !== null) { + const resolved = this[kVirtualCwd] + '/' + inputPath; + return normalizePath(resolved); + } + + // Fall back to normalizing the path (will use real cwd) + return normalizePath(inputPath); + } + + // ==================== Mount ==================== + + /** + * Mounts the VFS at a specific path prefix. + * @param {string} prefix The mount point path + */ + mount(prefix) { + if (this[kMounted]) { + throw new ERR_INVALID_STATE('VFS is already mounted'); + } + this[kMountPoint] = normalizePath(prefix); + this[kMounted] = true; + if (this[kModuleHooks]) { + loadModuleHooks(); + registerVFS(this); + } + if (this[kVirtualCwdEnabled]) { + this._hookProcessCwd(); + } + } + + /** + * Unmounts the VFS. + */ + unmount() { + this._unhookProcessCwd(); + if (this[kModuleHooks]) { + loadModuleHooks(); + unregisterVFS(this); + } + this[kMountPoint] = null; + this[kMounted] = false; + this[kVirtualCwd] = null; // Reset virtual cwd on unmount + } + + /** + * Hooks process.chdir and process.cwd to support virtual cwd. + * @private + */ + _hookProcessCwd() { + if (this[kOriginalChdir] !== null) { + return; + } + + const vfs = this; + + this[kOriginalChdir] = process.chdir; + this[kOriginalCwd] = process.cwd; + + process.chdir = function chdir(directory) { + const normalized = normalizePath(directory); + + if (vfs.shouldHandle(normalized)) { + vfs.chdir(normalized); + return; + } + + return vfs[kOriginalChdir].call(process, directory); + }; + + process.cwd = function cwd() { + if (vfs[kVirtualCwd] !== null) { + return vfs[kVirtualCwd]; + } + + return vfs[kOriginalCwd].call(process); + }; + } + + /** + * Restores original process.chdir and process.cwd. + * @private + */ + _unhookProcessCwd() { + if (this[kOriginalChdir] === null) { + return; + } + + process.chdir = this[kOriginalChdir]; + process.cwd = this[kOriginalCwd]; + + this[kOriginalChdir] = null; + this[kOriginalCwd] = null; + } + + // ==================== Path Resolution ==================== + + /** + * Converts a mounted path to a provider-relative path. + * @param {string} inputPath The path to convert + * @returns {string} The provider-relative path + * @private + */ + _toProviderPath(inputPath) { + const resolved = this.resolvePath(inputPath); + + if (this[kMounted] && this[kMountPoint]) { + if (!isUnderMountPoint(resolved, this[kMountPoint])) { + throw createENOENT('open', inputPath); + } + return getRelativePath(resolved, this[kMountPoint]); + } + + return resolved; + } + + /** + * Converts a provider-relative path to a mounted path. + * @param {string} providerPath The provider-relative path + * @returns {string} The mounted path + * @private + */ + _toMountedPath(providerPath) { + if (this[kMounted] && this[kMountPoint]) { + return joinMountPath(this[kMountPoint], providerPath); + } + return providerPath; + } + + /** + * Checks if a path should be handled by this VFS. + * @param {string} inputPath The path to check + * @returns {boolean} + */ + shouldHandle(inputPath) { + if (!this[kMounted] || !this[kMountPoint]) { + return false; + } + + const normalized = normalizePath(inputPath); + return isUnderMountPoint(normalized, this[kMountPoint]); + } + + // ==================== FS Operations (Sync) ==================== + + /** + * Checks if a path exists synchronously. + * @param {string} filePath The path to check + * @returns {boolean} + */ + existsSync(filePath) { + try { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].existsSync(providerPath); + } catch { + return false; + } + } + + /** + * Gets stats for a path synchronously. + * @param {string} filePath The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + statSync(filePath, options) { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].statSync(providerPath, options); + } + + /** + * Gets stats for a path synchronously without following symlinks. + * @param {string} filePath The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + lstatSync(filePath, options) { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].lstatSync(providerPath, options); + } + + /** + * Reads a file synchronously. + * @param {string} filePath The path to read + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(filePath, options) { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].readFileSync(providerPath, options); + } + + /** + * Writes a file synchronously. + * @param {string} filePath The path to write + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(filePath, data, options) { + const providerPath = this._toProviderPath(filePath); + this[kProvider].writeFileSync(providerPath, data, options); + } + + /** + * Appends to a file synchronously. + * @param {string} filePath The path to append to + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + */ + appendFileSync(filePath, data, options) { + const providerPath = this._toProviderPath(filePath); + this[kProvider].appendFileSync(providerPath, data, options); + } + + /** + * Reads directory contents synchronously. + * @param {string} dirPath The directory path + * @param {object} [options] Options + * @returns {string[]|Dirent[]} + */ + readdirSync(dirPath, options) { + const providerPath = this._toProviderPath(dirPath); + return this[kProvider].readdirSync(providerPath, options); + } + + /** + * Creates a directory synchronously. + * @param {string} dirPath The directory path + * @param {object} [options] Options + * @returns {string|undefined} + */ + mkdirSync(dirPath, options) { + const providerPath = this._toProviderPath(dirPath); + const result = this[kProvider].mkdirSync(providerPath, options); + if (result !== undefined) { + return this._toMountedPath(result); + } + return undefined; + } + + /** + * Removes a directory synchronously. + * @param {string} dirPath The directory path + */ + rmdirSync(dirPath) { + const providerPath = this._toProviderPath(dirPath); + this[kProvider].rmdirSync(providerPath); + } + + /** + * Removes a file synchronously. + * @param {string} filePath The file path + */ + unlinkSync(filePath) { + const providerPath = this._toProviderPath(filePath); + this[kProvider].unlinkSync(providerPath); + } + + /** + * Renames a file or directory synchronously. + * @param {string} oldPath The old path + * @param {string} newPath The new path + */ + renameSync(oldPath, newPath) { + const oldProviderPath = this._toProviderPath(oldPath); + const newProviderPath = this._toProviderPath(newPath); + this[kProvider].renameSync(oldProviderPath, newProviderPath); + } + + /** + * Copies a file synchronously. + * @param {string} src Source path + * @param {string} dest Destination path + * @param {number} [mode] Copy mode flags + */ + copyFileSync(src, dest, mode) { + const srcProviderPath = this._toProviderPath(src); + const destProviderPath = this._toProviderPath(dest); + this[kProvider].copyFileSync(srcProviderPath, destProviderPath, mode); + } + + /** + * Gets the real path by resolving all symlinks. + * @param {string} filePath The path + * @param {object} [options] Options + * @returns {string} + */ + realpathSync(filePath, options) { + const providerPath = this._toProviderPath(filePath); + const realProviderPath = this[kProvider].realpathSync(providerPath, options); + return this._toMountedPath(realProviderPath); + } + + /** + * Reads the target of a symbolic link. + * @param {string} linkPath The symlink path + * @param {object} [options] Options + * @returns {string} + */ + readlinkSync(linkPath, options) { + const providerPath = this._toProviderPath(linkPath); + return this[kProvider].readlinkSync(providerPath, options); + } + + /** + * Creates a symbolic link. + * @param {string} target The symlink target + * @param {string} path The symlink path + * @param {string} [type] The symlink type + */ + symlinkSync(target, path, type) { + const providerPath = this._toProviderPath(path); + this[kProvider].symlinkSync(target, providerPath, type); + } + + /** + * Checks file accessibility synchronously. + * @param {string} filePath The path to check + * @param {number} [mode] Access mode + */ + accessSync(filePath, mode) { + const providerPath = this._toProviderPath(filePath); + this[kProvider].accessSync(providerPath, mode); + } + + /** + * Returns the stat result code for module resolution. + * @param {string} filePath The path to check + * @returns {number} 0 for file, 1 for directory, -2 for not found + */ + internalModuleStat(filePath) { + try { + const providerPath = this._toProviderPath(filePath); + return this[kProvider].internalModuleStat(providerPath); + } catch { + return -2; + } + } + + // ==================== File Descriptor Operations ==================== + + /** + * Opens a file synchronously and returns a file descriptor. + * @param {string} filePath The path to open + * @param {string} [flags] Open flags + * @param {number} [mode] File mode + * @returns {number} The file descriptor + */ + openSync(filePath, flags = 'r', mode) { + const providerPath = this._toProviderPath(filePath); + const handle = this[kProvider].openSync(providerPath, flags, mode); + return openVirtualFd(handle, flags, this._toMountedPath(providerPath)); + } + + /** + * Closes a file descriptor synchronously. + * @param {number} fd The file descriptor + */ + closeSync(fd) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('close'); + } + vfd.entry.closeSync(); + closeVirtualFd(fd); + } + + /** + * Reads from a file descriptor synchronously. + * @param {number} fd The file descriptor + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer + * @param {number} length The number of bytes to read + * @param {number|null} position The position in the file + * @returns {number} The number of bytes read + */ + readSync(fd, buffer, offset, length, position) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('read'); + } + return vfd.entry.readSync(buffer, offset, length, position); + } + + /** + * Gets file stats from a file descriptor synchronously. + * @param {number} fd The file descriptor + * @param {object} [options] Options + * @returns {Stats} + */ + fstatSync(fd, options) { + const vfd = getVirtualFd(fd); + if (!vfd) { + throw createEBADF('fstat'); + } + return vfd.entry.statSync(options); + } + + // ==================== FS Operations (Async with Callbacks) ==================== + + /** + * Reads a file asynchronously. + * @param {string} filePath The path to read + * @param {object|string|Function} [options] Options, encoding, or callback + * @param {Function} [callback] Callback (err, data) + */ + readFile(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].readFile(this._toProviderPath(filePath), options) + .then((data) => callback(null, data)) + .catch((err) => callback(err)); + } + + /** + * Writes a file asynchronously. + * @param {string} filePath The path to write + * @param {Buffer|string} data The data to write + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err) + */ + writeFile(filePath, data, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].writeFile(this._toProviderPath(filePath), data, options) + .then(() => callback(null)) + .catch((err) => callback(err)); + } + + /** + * Gets stats for a path asynchronously. + * @param {string} filePath The path to stat + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + stat(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].stat(this._toProviderPath(filePath), options) + .then((stats) => callback(null, stats)) + .catch((err) => callback(err)); + } + + /** + * Gets stats without following symlinks asynchronously. + * @param {string} filePath The path to stat + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + lstat(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].lstat(this._toProviderPath(filePath), options) + .then((stats) => callback(null, stats)) + .catch((err) => callback(err)); + } + + /** + * Reads directory contents asynchronously. + * @param {string} dirPath The directory path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, entries) + */ + readdir(dirPath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].readdir(this._toProviderPath(dirPath), options) + .then((entries) => callback(null, entries)) + .catch((err) => callback(err)); + } + + /** + * Gets the real path asynchronously. + * @param {string} filePath The path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, resolvedPath) + */ + realpath(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].realpath(this._toProviderPath(filePath), options) + .then((realPath) => callback(null, this._toMountedPath(realPath))) + .catch((err) => callback(err)); + } + + /** + * Reads symlink target asynchronously. + * @param {string} linkPath The symlink path + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, target) + */ + readlink(linkPath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + this[kProvider].readlink(this._toProviderPath(linkPath), options) + .then((target) => callback(null, target)) + .catch((err) => callback(err)); + } + + /** + * Checks file accessibility asynchronously. + * @param {string} filePath The path to check + * @param {number|Function} [mode] Access mode or callback + * @param {Function} [callback] Callback (err) + */ + access(filePath, mode, callback) { + if (typeof mode === 'function') { + callback = mode; + mode = undefined; + } + + this[kProvider].access(this._toProviderPath(filePath), mode) + .then(() => callback(null)) + .catch((err) => callback(err)); + } + + /** + * Opens a file asynchronously. + * @param {string} filePath The path to open + * @param {string|Function} [flags] Open flags or callback + * @param {number|Function} [mode] File mode or callback + * @param {Function} [callback] Callback (err, fd) + */ + open(filePath, flags, mode, callback) { + if (typeof flags === 'function') { + callback = flags; + flags = 'r'; + mode = undefined; + } else if (typeof mode === 'function') { + callback = mode; + mode = undefined; + } + + const providerPath = this._toProviderPath(filePath); + this[kProvider].open(providerPath, flags, mode) + .then((handle) => { + const fd = openVirtualFd(handle, flags, this._toMountedPath(providerPath)); + callback(null, fd); + }) + .catch((err) => callback(err)); + } + + /** + * Closes a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {Function} callback Callback (err) + */ + close(fd, callback) { + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('close')); + return; + } + + vfd.entry.close() + .then(() => { + closeVirtualFd(fd); + callback(null); + }) + .catch((err) => callback(err)); + } + + /** + * Reads from a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {Buffer} buffer The buffer to read into + * @param {number} offset The offset in the buffer + * @param {number} length The number of bytes to read + * @param {number|null} position The position in the file + * @param {Function} callback Callback (err, bytesRead, buffer) + */ + read(fd, buffer, offset, length, position, callback) { + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('read')); + return; + } + + vfd.entry.read(buffer, offset, length, position) + .then(({ bytesRead }) => callback(null, bytesRead, buffer)) + .catch((err) => callback(err)); + } + + /** + * Gets file stats from a file descriptor asynchronously. + * @param {number} fd The file descriptor + * @param {object|Function} [options] Options or callback + * @param {Function} [callback] Callback (err, stats) + */ + fstat(fd, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + const vfd = getVirtualFd(fd); + if (!vfd) { + process.nextTick(callback, createEBADF('fstat')); + return; + } + + vfd.entry.stat(options) + .then((stats) => callback(null, stats)) + .catch((err) => callback(err)); + } + + // ==================== Stream Operations ==================== + + /** + * Creates a readable stream for a virtual file. + * @param {string} filePath The path to the file + * @param {object} [options] Stream options + * @returns {ReadStream} + */ + createReadStream(filePath, options) { + return createVirtualReadStream(this, filePath, options); + } + + // ==================== Promise API ==================== + + /** + * Gets the promises API for this VFS instance. + * @returns {object} Promise-based fs methods + */ + get promises() { + if (this[kPromises] === null) { + this[kPromises] = createPromisesAPI(this); + } + return this[kPromises]; + } +} + +/** + * Creates the promises API object for a VFS instance. + * @param {VirtualFileSystem} vfs The VFS instance + * @returns {object} Promise-based fs methods + */ +function createPromisesAPI(vfs) { + const provider = vfs[kProvider]; + + return ObjectFreeze({ + async readFile(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.readFile(providerPath, options); + }, + + async writeFile(filePath, data, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.writeFile(providerPath, data, options); + }, + + async appendFile(filePath, data, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.appendFile(providerPath, data, options); + }, + + async stat(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.stat(providerPath, options); + }, + + async lstat(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + return provider.lstat(providerPath, options); + }, + + async readdir(dirPath, options) { + const providerPath = vfs._toProviderPath(dirPath); + return provider.readdir(providerPath, options); + }, + + async mkdir(dirPath, options) { + const providerPath = vfs._toProviderPath(dirPath); + const result = await provider.mkdir(providerPath, options); + if (result !== undefined) { + return vfs._toMountedPath(result); + } + return undefined; + }, + + async rmdir(dirPath) { + const providerPath = vfs._toProviderPath(dirPath); + return provider.rmdir(providerPath); + }, + + async unlink(filePath) { + const providerPath = vfs._toProviderPath(filePath); + return provider.unlink(providerPath); + }, + + async rename(oldPath, newPath) { + const oldProviderPath = vfs._toProviderPath(oldPath); + const newProviderPath = vfs._toProviderPath(newPath); + return provider.rename(oldProviderPath, newProviderPath); + }, + + async copyFile(src, dest, mode) { + const srcProviderPath = vfs._toProviderPath(src); + const destProviderPath = vfs._toProviderPath(dest); + return provider.copyFile(srcProviderPath, destProviderPath, mode); + }, + + async realpath(filePath, options) { + const providerPath = vfs._toProviderPath(filePath); + const realPath = await provider.realpath(providerPath, options); + return vfs._toMountedPath(realPath); + }, + + async readlink(linkPath, options) { + const providerPath = vfs._toProviderPath(linkPath); + return provider.readlink(providerPath, options); + }, + + async symlink(target, path, type) { + const providerPath = vfs._toProviderPath(path); + return provider.symlink(target, providerPath, type); + }, + + async access(filePath, mode) { + const providerPath = vfs._toProviderPath(filePath); + return provider.access(providerPath, mode); + }, + }); +} + +module.exports = { + VirtualFileSystem, +}; diff --git a/lib/internal/vfs/provider.js b/lib/internal/vfs/provider.js new file mode 100644 index 000000000000..5b5ac432d7d8 --- /dev/null +++ b/lib/internal/vfs/provider.js @@ -0,0 +1,499 @@ +'use strict'; + +const { + ERR_METHOD_NOT_IMPLEMENTED, +} = require('internal/errors').codes; + +const { + createEROFS, +} = require('internal/vfs/errors'); + +/** + * Base class for VFS providers. + * Providers implement the essential primitives that the VFS delegates to. + * + * Implementations must override the essential primitives (open, stat, readdir, etc.) + * Default implementations for derived methods (readFile, writeFile, etc.) are provided. + */ +class VirtualProvider { + // === CAPABILITY FLAGS === + + /** + * Returns true if this provider is read-only. + * @returns {boolean} + */ + get readonly() { + return false; + } + + /** + * Returns true if this provider supports symbolic links. + * @returns {boolean} + */ + get supportsSymlinks() { + return false; + } + + // === ESSENTIAL PRIMITIVES (must be implemented by subclasses) === + + /** + * Opens a file and returns a file handle. + * @param {string} path The file path (relative to provider root) + * @param {string} flags The open flags ('r', 'r+', 'w', 'w+', 'a', 'a+') + * @param {number} [mode] The file mode (for creating files) + * @returns {Promise} + */ + async open(path, flags, mode) { + throw new ERR_METHOD_NOT_IMPLEMENTED('open'); + } + + /** + * Opens a file synchronously and returns a file handle. + * @param {string} path The file path (relative to provider root) + * @param {string} flags The open flags ('r', 'r+', 'w', 'w+', 'a', 'a+') + * @param {number} [mode] The file mode (for creating files) + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + openSync(path, flags, mode) { + throw new ERR_METHOD_NOT_IMPLEMENTED('openSync'); + } + + /** + * Gets stats for a path. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Promise} + */ + async stat(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('stat'); + } + + /** + * Gets stats for a path synchronously. + * @param {string} path The path to stat + * @param {object} [options] Options + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + statSync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('statSync'); + } + + /** + * Gets stats for a path without following symlinks. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Promise} + */ + async lstat(path, options) { + // Default: same as stat (for providers that don't support symlinks) + return this.stat(path, options); + } + + /** + * Gets stats for a path synchronously without following symlinks. + * @param {string} path The path to stat + * @param {object} [options] Options + * @returns {Stats} + */ + lstatSync(path, options) { + // Default: same as statSync (for providers that don't support symlinks) + return this.statSync(path, options); + } + + /** + * Reads directory contents. + * @param {string} path The directory path + * @param {object} [options] Options + * @returns {Promise} + */ + async readdir(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readdir'); + } + + /** + * Reads directory contents synchronously. + * @param {string} path The directory path + * @param {object} [options] Options + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + readdirSync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readdirSync'); + } + + /** + * Creates a directory. + * @param {string} path The directory path + * @param {object} [options] Options + * @returns {Promise} + */ + async mkdir(path, options) { + if (this.readonly) { + throw createEROFS('mkdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('mkdir'); + } + + /** + * Creates a directory synchronously. + * @param {string} path The directory path + * @param {object} [options] Options + */ + mkdirSync(path, options) { + if (this.readonly) { + throw createEROFS('mkdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('mkdirSync'); + } + + /** + * Removes a directory. + * @param {string} path The directory path + * @returns {Promise} + */ + async rmdir(path) { + if (this.readonly) { + throw createEROFS('rmdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('rmdir'); + } + + /** + * Removes a directory synchronously. + * @param {string} path The directory path + */ + rmdirSync(path) { + if (this.readonly) { + throw createEROFS('rmdir', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('rmdirSync'); + } + + /** + * Removes a file. + * @param {string} path The file path + * @returns {Promise} + */ + async unlink(path) { + if (this.readonly) { + throw createEROFS('unlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('unlink'); + } + + /** + * Removes a file synchronously. + * @param {string} path The file path + */ + unlinkSync(path) { + if (this.readonly) { + throw createEROFS('unlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('unlinkSync'); + } + + /** + * Renames a file or directory. + * @param {string} oldPath The old path + * @param {string} newPath The new path + * @returns {Promise} + */ + async rename(oldPath, newPath) { + if (this.readonly) { + throw createEROFS('rename', oldPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('rename'); + } + + /** + * Renames a file or directory synchronously. + * @param {string} oldPath The old path + * @param {string} newPath The new path + */ + renameSync(oldPath, newPath) { + if (this.readonly) { + throw createEROFS('rename', oldPath); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('renameSync'); + } + + // === DEFAULT IMPLEMENTATIONS (built on primitives) === + + /** + * Reads a file. + * @param {string} path The file path + * @param {object|string} [options] Options or encoding + * @returns {Promise} + */ + async readFile(path, options) { + const handle = await this.open(path, 'r'); + try { + return await handle.readFile(options); + } finally { + await handle.close(); + } + } + + /** + * Reads a file synchronously. + * @param {string} path The file path + * @param {object|string} [options] Options or encoding + * @returns {Buffer|string} + */ + readFileSync(path, options) { + const handle = this.openSync(path, 'r'); + try { + return handle.readFileSync(options); + } finally { + handle.closeSync(); + } + } + + /** + * Writes a file. + * @param {string} path The file path + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + * @returns {Promise} + */ + async writeFile(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const handle = await this.open(path, 'w', options?.mode); + try { + await handle.writeFile(data, options); + } finally { + await handle.close(); + } + } + + /** + * Writes a file synchronously. + * @param {string} path The file path + * @param {Buffer|string} data The data to write + * @param {object} [options] Options + */ + writeFileSync(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const handle = this.openSync(path, 'w', options?.mode); + try { + handle.writeFileSync(data, options); + } finally { + handle.closeSync(); + } + } + + /** + * Appends to a file. + * @param {string} path The file path + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + * @returns {Promise} + */ + async appendFile(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const handle = await this.open(path, 'a', options?.mode); + try { + await handle.writeFile(data, options); + } finally { + await handle.close(); + } + } + + /** + * Appends to a file synchronously. + * @param {string} path The file path + * @param {Buffer|string} data The data to append + * @param {object} [options] Options + */ + appendFileSync(path, data, options) { + if (this.readonly) { + throw createEROFS('open', path); + } + const handle = this.openSync(path, 'a', options?.mode); + try { + handle.writeFileSync(data, options); + } finally { + handle.closeSync(); + } + } + + /** + * Checks if a path exists. + * @param {string} path The path to check + * @returns {Promise} + */ + async exists(path) { + try { + await this.stat(path); + return true; + } catch { + return false; + } + } + + /** + * Checks if a path exists synchronously. + * @param {string} path The path to check + * @returns {boolean} + */ + existsSync(path) { + try { + this.statSync(path); + return true; + } catch { + return false; + } + } + + /** + * Copies a file. + * @param {string} src Source path + * @param {string} dest Destination path + * @param {number} [mode] Copy mode flags + * @returns {Promise} + */ + async copyFile(src, dest, mode) { + if (this.readonly) { + throw createEROFS('copyfile', dest); + } + const content = await this.readFile(src); + await this.writeFile(dest, content); + } + + /** + * Copies a file synchronously. + * @param {string} src Source path + * @param {string} dest Destination path + * @param {number} [mode] Copy mode flags + */ + copyFileSync(src, dest, mode) { + if (this.readonly) { + throw createEROFS('copyfile', dest); + } + const content = this.readFileSync(src); + this.writeFileSync(dest, content); + } + + /** + * Returns the stat result code for module resolution. + * Used by Module._stat override. + * @param {string} path The path to check + * @returns {number} 0 for file, 1 for directory, -2 for not found + */ + internalModuleStat(path) { + try { + const stats = this.statSync(path); + if (stats.isDirectory()) { + return 1; + } + return 0; + } catch { + return -2; // ENOENT + } + } + + /** + * Gets the real path by resolving symlinks. + * @param {string} path The path + * @param {object} [options] Options + * @returns {Promise} + */ + async realpath(path, options) { + // Default: return the path as-is (for providers without symlinks) + // First verify the path exists + await this.stat(path); + return path; + } + + /** + * Gets the real path synchronously. + * @param {string} path The path + * @param {object} [options] Options + * @returns {string} + */ + realpathSync(path, options) { + // Default: return the path as-is (for providers without symlinks) + // First verify the path exists + this.statSync(path); + return path; + } + + /** + * Checks file accessibility. + * @param {string} path The path to check + * @param {number} [mode] Access mode + * @returns {Promise} + */ + async access(path, mode) { + // Default: just check if the path exists + await this.stat(path); + } + + /** + * Checks file accessibility synchronously. + * @param {string} path The path to check + * @param {number} [mode] Access mode + */ + accessSync(path, mode) { + // Default: just check if the path exists + this.statSync(path); + } + + // === SYMLINK OPERATIONS (optional, throw ENOENT by default) === + + /** + * Reads the target of a symbolic link. + * @param {string} path The symlink path + * @param {object} [options] Options + * @returns {Promise} + */ + async readlink(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readlink'); + } + + /** + * Reads the target of a symbolic link synchronously. + * @param {string} path The symlink path + * @param {object} [options] Options + * @throws {ERR_METHOD_NOT_IMPLEMENTED} When not implemented by subclass + */ + readlinkSync(path, options) { + throw new ERR_METHOD_NOT_IMPLEMENTED('readlinkSync'); + } + + /** + * Creates a symbolic link. + * @param {string} target The symlink target + * @param {string} path The symlink path + * @param {string} [type] The symlink type (file, dir, junction) + * @returns {Promise} + */ + async symlink(target, path, type) { + if (this.readonly) { + throw createEROFS('symlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('symlink'); + } + + /** + * Creates a symbolic link synchronously. + * @param {string} target The symlink target + * @param {string} path The symlink path + * @param {string} [type] The symlink type (file, dir, junction) + */ + symlinkSync(target, path, type) { + if (this.readonly) { + throw createEROFS('symlink', path); + } + throw new ERR_METHOD_NOT_IMPLEMENTED('symlinkSync'); + } +} + +module.exports = { + VirtualProvider, +}; diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js new file mode 100644 index 000000000000..890afaa97185 --- /dev/null +++ b/lib/internal/vfs/providers/memory.js @@ -0,0 +1,700 @@ +'use strict'; + +const { + ArrayPrototypePush, + DateNow, + SafeMap, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { MemoryFileHandle } = require('internal/vfs/file_handle'); +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { + createENOENT, + createENOTDIR, + createENOTEMPTY, + createEISDIR, + createEEXIST, + createEINVAL, + createELOOP, +} = require('internal/vfs/errors'); +const { + createFileStats, + createDirectoryStats, + createSymlinkStats, +} = require('internal/vfs/stats'); +const { Dirent } = require('internal/fs/utils'); +const { + fs: { + UV_DIRENT_FILE, + UV_DIRENT_DIR, + UV_DIRENT_LINK, + }, +} = internalBinding('constants'); + +// Private symbols +const kRoot = Symbol('kRoot'); +const kReadonly = Symbol('kReadonly'); + +// Entry types +const TYPE_FILE = 0; +const TYPE_DIR = 1; +const TYPE_SYMLINK = 2; + +// Maximum symlink resolution depth +const kMaxSymlinkDepth = 40; + +/** + * Internal entry representation for MemoryProvider. + */ +class MemoryEntry { + constructor(type, options = {}) { + this.type = type; + this.mode = options.mode ?? (type === TYPE_DIR ? 0o755 : 0o644); + this.content = null; // For files - static Buffer content + this.contentProvider = null; // For files - dynamic content function + this.target = null; // For symlinks + this.children = null; // For directories + this.populate = null; // For directories - lazy population callback + this.populated = true; // For directories - has populate been called? + this.mtime = DateNow(); + this.ctime = DateNow(); + this.birthtime = DateNow(); + } + + /** + * Gets the file content synchronously. + * Throws if the content provider returns a Promise. + * @returns {Buffer} The file content + */ + getContentSync() { + if (this.contentProvider !== null) { + const result = this.contentProvider(); + if (result && typeof result.then === 'function') { + // It's a Promise - can't use sync API + throw new ERR_INVALID_STATE('cannot use sync API with async content provider'); + } + return typeof result === 'string' ? Buffer.from(result) : result; + } + return this.content; + } + + /** + * Gets the file content asynchronously. + * @returns {Promise} The file content + */ + async getContentAsync() { + if (this.contentProvider !== null) { + const result = await this.contentProvider(); + return typeof result === 'string' ? Buffer.from(result) : result; + } + return this.content; + } + + /** + * Returns true if this file has a dynamic content provider. + * @returns {boolean} + */ + isDynamic() { + return this.contentProvider !== null; + } + + isFile() { + return this.type === TYPE_FILE; + } + + isDirectory() { + return this.type === TYPE_DIR; + } + + isSymbolicLink() { + return this.type === TYPE_SYMLINK; + } +} + +/** + * In-memory filesystem provider. + * Supports full read/write operations. + */ +class MemoryProvider extends VirtualProvider { + constructor() { + super(); + // Root directory + this[kRoot] = new MemoryEntry(TYPE_DIR); + this[kRoot].children = new SafeMap(); + this[kReadonly] = false; + } + + get readonly() { + return this[kReadonly]; + } + + /** + * Sets the provider to read-only mode. + * Once set to read-only, the provider cannot be changed back to writable. + * This is useful for finalizing a VFS after initial population. + */ + setReadOnly() { + this[kReadonly] = true; + } + + get supportsSymlinks() { + return true; + } + + /** + * Normalizes a path to use forward slashes, removes trailing slash, + * and resolves . and .. components. + * @param {string} path The path to normalize + * @returns {string} Normalized path + */ + _normalizePath(path) { + // Normalize slashes + let normalized = path.replace(/\\/g, '/'); + if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + + // Split into segments and resolve . and .. + const segments = normalized.split('/').filter((s) => s !== '' && s !== '.'); + const resolved = []; + for (const segment of segments) { + if (segment === '..') { + // Go up one level (but don't go above root) + if (resolved.length > 0) { + resolved.pop(); + } + } else { + resolved.push(segment); + } + } + + return '/' + resolved.join('/'); + } + + /** + * Splits a path into segments. + * @param {string} path Normalized path + * @returns {string[]} Path segments + */ + _splitPath(path) { + if (path === '/') { + return []; + } + return path.slice(1).split('/'); + } + + /** + * Gets the parent path. + * @param {string} path Normalized path + * @returns {string|null} Parent path or null for root + */ + _getParentPath(path) { + if (path === '/') { + return null; + } + const lastSlash = path.lastIndexOf('/'); + if (lastSlash === 0) { + return '/'; + } + return path.slice(0, lastSlash); + } + + /** + * Gets the base name. + * @param {string} path Normalized path + * @returns {string} Base name + */ + _getBaseName(path) { + const lastSlash = path.lastIndexOf('/'); + return path.slice(lastSlash + 1); + } + + /** + * Resolves a symlink target to an absolute path. + * @param {string} symlinkPath The path of the symlink + * @param {string} target The symlink target + * @returns {string} Resolved absolute path + */ + _resolveSymlinkTarget(symlinkPath, target) { + if (target.startsWith('/')) { + return this._normalizePath(target); + } + // Relative target: resolve against symlink's parent directory + const parentPath = this._getParentPath(symlinkPath); + if (parentPath === null) { + return this._normalizePath('/' + target); + } + return this._normalizePath(parentPath + '/' + target); + } + + /** + * Looks up an entry by path, optionally following symlinks. + * @param {string} path The path to look up + * @param {boolean} followSymlinks Whether to follow symlinks + * @param {number} depth Current symlink resolution depth + * @returns {{ entry: MemoryEntry|null, resolvedPath: string|null, eloop?: boolean }} + */ + _lookupEntry(path, followSymlinks = true, depth = 0) { + const normalized = this._normalizePath(path); + + if (normalized === '/') { + return { entry: this[kRoot], resolvedPath: '/' }; + } + + const segments = this._splitPath(normalized); + let current = this[kRoot]; + let currentPath = ''; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Follow symlinks for intermediate path components + if (current.isSymbolicLink() && followSymlinks) { + if (depth >= kMaxSymlinkDepth) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const targetPath = this._resolveSymlinkTarget(currentPath, current.target); + const result = this._lookupEntry(targetPath, true, depth + 1); + if (result.eloop) { + return result; + } + if (!result.entry) { + return { entry: null, resolvedPath: null }; + } + current = result.entry; + currentPath = result.resolvedPath; + } + + if (!current.isDirectory()) { + return { entry: null, resolvedPath: null }; + } + + // Ensure directory is populated before accessing children + this._ensurePopulated(current, currentPath || '/'); + + const entry = current.children.get(segment); + if (!entry) { + return { entry: null, resolvedPath: null }; + } + + currentPath = currentPath + '/' + segment; + current = entry; + } + + // Follow symlink at the end if requested + if (current.isSymbolicLink() && followSymlinks) { + if (depth >= kMaxSymlinkDepth) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const targetPath = this._resolveSymlinkTarget(currentPath, current.target); + return this._lookupEntry(targetPath, true, depth + 1); + } + + return { entry: current, resolvedPath: currentPath }; + } + + /** + * Gets an entry by path, throwing if not found. + * @param {string} path The path + * @param {string} syscall The syscall name for error + * @param {boolean} followSymlinks Whether to follow symlinks + * @returns {MemoryEntry} + */ + _getEntry(path, syscall, followSymlinks = true) { + const result = this._lookupEntry(path, followSymlinks); + if (result.eloop) { + throw createELOOP(syscall, path); + } + if (!result.entry) { + throw createENOENT(syscall, path); + } + return result.entry; + } + + /** + * Ensures parent directories exist, optionally creating them. + * @param {string} path The full path + * @param {boolean} create Whether to create missing directories + * @param {string} syscall The syscall name for errors + * @returns {MemoryEntry} The parent directory entry + */ + _ensureParent(path, create, syscall) { + const parentPath = this._getParentPath(path); + if (parentPath === null) { + return this[kRoot]; + } + + const segments = this._splitPath(parentPath); + let current = this[kRoot]; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Follow symlinks in parent path + if (current.isSymbolicLink()) { + const currentPath = '/' + segments.slice(0, i).join('/'); + const targetPath = this._resolveSymlinkTarget(currentPath, current.target); + const result = this._lookupEntry(targetPath, true, 0); + if (!result.entry) { + throw createENOENT(syscall, path); + } + current = result.entry; + } + + if (!current.isDirectory()) { + throw createENOTDIR(syscall, path); + } + + // Ensure directory is populated before accessing children + const currentPath = '/' + segments.slice(0, i).join('/'); + this._ensurePopulated(current, currentPath || '/'); + + let entry = current.children.get(segment); + if (!entry) { + if (create) { + entry = new MemoryEntry(TYPE_DIR); + entry.children = new SafeMap(); + current.children.set(segment, entry); + } else { + throw createENOENT(syscall, path); + } + } + current = entry; + } + + if (!current.isDirectory()) { + throw createENOTDIR(syscall, path); + } + + // Ensure final directory is populated + const finalPath = '/' + segments.join('/'); + this._ensurePopulated(current, finalPath); + + return current; + } + + /** + * Creates stats for an entry. + * @param {MemoryEntry} entry The entry + * @param {number} [size] Override size for files + * @returns {Stats} + */ + _createStats(entry, size) { + const options = { + mode: entry.mode, + mtimeMs: entry.mtime, + ctimeMs: entry.ctime, + birthtimeMs: entry.birthtime, + }; + + if (entry.isFile()) { + return createFileStats(size !== undefined ? size : entry.content.length, options); + } else if (entry.isDirectory()) { + return createDirectoryStats(options); + } else if (entry.isSymbolicLink()) { + return createSymlinkStats(entry.target.length, options); + } + + throw new ERR_INVALID_STATE('Unknown entry type'); + } + + /** + * Ensures a directory is populated by calling its populate callback if needed. + * @param {MemoryEntry} entry The directory entry + * @param {string} path The directory path (for error messages and scoped VFS) + */ + _ensurePopulated(entry, path) { + if (entry.isDirectory() && !entry.populated && entry.populate) { + // Create a scoped VFS for the populate callback + const scopedVfs = { + addFile: (name, content, opts) => { + const fileEntry = new MemoryEntry(TYPE_FILE, opts); + if (typeof content === 'function') { + fileEntry.content = Buffer.alloc(0); + fileEntry.contentProvider = content; + } else { + fileEntry.content = typeof content === 'string' ? Buffer.from(content) : content; + } + entry.children.set(name, fileEntry); + }, + addDirectory: (name, populate, opts) => { + const dirEntry = new MemoryEntry(TYPE_DIR, opts); + dirEntry.children = new SafeMap(); + if (typeof populate === 'function') { + dirEntry.populate = populate; + dirEntry.populated = false; + } + entry.children.set(name, dirEntry); + }, + addSymlink: (name, target, opts) => { + const symlinkEntry = new MemoryEntry(TYPE_SYMLINK, opts); + symlinkEntry.target = target; + entry.children.set(name, symlinkEntry); + }, + }; + entry.populate(scopedVfs); + entry.populated = true; + } + } + + openSync(path, flags, mode) { + const normalized = this._normalizePath(path); + + // Handle create modes + const isCreate = flags === 'w' || flags === 'w+' || flags === 'a' || flags === 'a+'; + + let entry; + try { + entry = this._getEntry(normalized, 'open'); + } catch (err) { + if (err.code === 'ENOENT' && isCreate) { + // Create the file + const parent = this._ensureParent(normalized, true, 'open'); + const name = this._getBaseName(normalized); + entry = new MemoryEntry(TYPE_FILE, { mode }); + entry.content = Buffer.alloc(0); + parent.children.set(name, entry); + } else { + throw err; + } + } + + if (entry.isDirectory()) { + throw createEISDIR('open', path); + } + + if (entry.isSymbolicLink()) { + // Should have been resolved already, but just in case + throw createEINVAL('open', path); + } + + const getStats = (size) => this._createStats(entry, size); + return new MemoryFileHandle(normalized, flags, mode ?? entry.mode, entry.content, entry, getStats); + } + + async open(path, flags, mode) { + return this.openSync(path, flags, mode); + } + + statSync(path, options) { + const entry = this._getEntry(path, 'stat', true); + return this._createStats(entry); + } + + async stat(path, options) { + return this.statSync(path, options); + } + + lstatSync(path, options) { + const entry = this._getEntry(path, 'lstat', false); + return this._createStats(entry); + } + + async lstat(path, options) { + return this.lstatSync(path, options); + } + + readdirSync(path, options) { + const entry = this._getEntry(path, 'scandir', true); + if (!entry.isDirectory()) { + throw createENOTDIR('scandir', path); + } + + // Ensure directory is populated (for lazy population) + this._ensurePopulated(entry, path); + + const names = [...entry.children.keys()]; + + if (options?.withFileTypes) { + const normalized = this._normalizePath(path); + const dirents = []; + for (const name of names) { + const childEntry = entry.children.get(name); + let type; + if (childEntry.isSymbolicLink()) { + type = UV_DIRENT_LINK; + } else if (childEntry.isDirectory()) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; + } + ArrayPrototypePush(dirents, new Dirent(name, type, normalized)); + } + return dirents; + } + + return names; + } + + async readdir(path, options) { + return this.readdirSync(path, options); + } + + mkdirSync(path, options) { + const normalized = this._normalizePath(path); + const recursive = options?.recursive === true; + + // Check if already exists + const existing = this._lookupEntry(normalized, true); + if (existing.entry) { + if (existing.entry.isDirectory() && recursive) { + // Already exists, that's ok for recursive + return undefined; + } + throw createEEXIST('mkdir', path); + } + + if (recursive) { + // Create all parent directories + const segments = this._splitPath(normalized); + let current = this[kRoot]; + let currentPath = ''; + + for (const segment of segments) { + currentPath = currentPath + '/' + segment; + let entry = current.children.get(segment); + if (!entry) { + entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new SafeMap(); + current.children.set(segment, entry); + } else if (!entry.isDirectory()) { + throw createENOTDIR('mkdir', path); + } + current = entry; + } + } else { + const parent = this._ensureParent(normalized, false, 'mkdir'); + const name = this._getBaseName(normalized); + const entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new SafeMap(); + parent.children.set(name, entry); + } + + return recursive ? normalized : undefined; + } + + async mkdir(path, options) { + return this.mkdirSync(path, options); + } + + rmdirSync(path) { + const normalized = this._normalizePath(path); + const entry = this._getEntry(normalized, 'rmdir', true); + + if (!entry.isDirectory()) { + throw createENOTDIR('rmdir', path); + } + + if (entry.children.size > 0) { + throw createENOTEMPTY('rmdir', path); + } + + const parent = this._ensureParent(normalized, false, 'rmdir'); + const name = this._getBaseName(normalized); + parent.children.delete(name); + } + + async rmdir(path) { + this.rmdirSync(path); + } + + unlinkSync(path) { + const normalized = this._normalizePath(path); + const entry = this._getEntry(normalized, 'unlink', false); + + if (entry.isDirectory()) { + throw createEISDIR('unlink', path); + } + + const parent = this._ensureParent(normalized, false, 'unlink'); + const name = this._getBaseName(normalized); + parent.children.delete(name); + } + + async unlink(path) { + this.unlinkSync(path); + } + + renameSync(oldPath, newPath) { + const normalizedOld = this._normalizePath(oldPath); + const normalizedNew = this._normalizePath(newPath); + + // Get the entry (without following symlinks for the entry itself) + const entry = this._getEntry(normalizedOld, 'rename', false); + + // Remove from old location + const oldParent = this._ensureParent(normalizedOld, false, 'rename'); + const oldName = this._getBaseName(normalizedOld); + oldParent.children.delete(oldName); + + // Add to new location + const newParent = this._ensureParent(normalizedNew, true, 'rename'); + const newName = this._getBaseName(normalizedNew); + newParent.children.set(newName, entry); + } + + async rename(oldPath, newPath) { + this.renameSync(oldPath, newPath); + } + + readlinkSync(path, options) { + const normalized = this._normalizePath(path); + const entry = this._getEntry(normalized, 'readlink', false); + + if (!entry.isSymbolicLink()) { + throw createEINVAL('readlink', path); + } + + return entry.target; + } + + async readlink(path, options) { + return this.readlinkSync(path, options); + } + + symlinkSync(target, path, type) { + const normalized = this._normalizePath(path); + + // Check if already exists + const existing = this._lookupEntry(normalized, false); + if (existing.entry) { + throw createEEXIST('symlink', path); + } + + const parent = this._ensureParent(normalized, true, 'symlink'); + const name = this._getBaseName(normalized); + const entry = new MemoryEntry(TYPE_SYMLINK); + entry.target = target; + parent.children.set(name, entry); + } + + async symlink(target, path, type) { + this.symlinkSync(target, path, type); + } + + realpathSync(path, options) { + const result = this._lookupEntry(path, true, 0); + if (result.eloop) { + throw createELOOP('realpath', path); + } + if (!result.entry) { + throw createENOENT('realpath', path); + } + return result.resolvedPath; + } + + async realpath(path, options) { + return this.realpathSync(path, options); + } +} + +module.exports = { + MemoryProvider, +}; diff --git a/lib/internal/vfs/providers/sea.js b/lib/internal/vfs/providers/sea.js new file mode 100644 index 000000000000..7d55a41334da --- /dev/null +++ b/lib/internal/vfs/providers/sea.js @@ -0,0 +1,429 @@ +'use strict'; + +const { + ArrayPrototypePush, + Boolean, + MathMin, + SafeMap, + SafeSet, + StringPrototypeStartsWith, + Symbol, +} = primordials; + +const { Buffer } = require('buffer'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { VirtualFileHandle } = require('internal/vfs/file_handle'); +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { + createENOENT, + createENOTDIR, + createEISDIR, + createEROFS, +} = require('internal/vfs/errors'); +const { + createFileStats, + createDirectoryStats, +} = require('internal/vfs/stats'); +const { Dirent } = require('internal/fs/utils'); +const { + fs: { + UV_DIRENT_FILE, + UV_DIRENT_DIR, + }, +} = internalBinding('constants'); + +// Private symbols +const kAssets = Symbol('kAssets'); +const kDirectories = Symbol('kDirectories'); +const kGetAsset = Symbol('kGetAsset'); + +/** + * File handle for SEA assets (read-only). + */ +class SEAFileHandle extends VirtualFileHandle { + #content; + #getStats; + + /** + * @param {string} path The file path + * @param {Buffer} content The file content + * @param {Function} getStats Function to get stats + */ + constructor(path, content, getStats) { + super(path, 'r', 0o444); + this.#content = content; + this.#getStats = getStats; + } + + readSync(buffer, offset, length, position) { + this._checkClosed(); + + const readPos = position !== null && position !== undefined ? position : this.position; + const available = this.#content.length - readPos; + + if (available <= 0) { + return 0; + } + + const bytesToRead = MathMin(length, available); + this.#content.copy(buffer, offset, readPos, readPos + bytesToRead); + + if (position === null || position === undefined) { + this.position = readPos + bytesToRead; + } + + return bytesToRead; + } + + async read(buffer, offset, length, position) { + const bytesRead = this.readSync(buffer, offset, length, position); + return { __proto__: null, bytesRead, buffer }; + } + + readFileSync(options) { + this._checkClosed(); + + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return this.#content.toString(encoding); + } + return Buffer.from(this.#content); + } + + async readFile(options) { + return this.readFileSync(options); + } + + writeSync() { + throw createEROFS('write', this.path); + } + + async write() { + throw createEROFS('write', this.path); + } + + writeFileSync() { + throw createEROFS('write', this.path); + } + + async writeFile() { + throw createEROFS('write', this.path); + } + + truncateSync() { + throw createEROFS('ftruncate', this.path); + } + + async truncate() { + throw createEROFS('ftruncate', this.path); + } + + statSync(options) { + this._checkClosed(); + return this.#getStats(); + } + + async stat(options) { + return this.statSync(options); + } +} + +/** + * Read-only provider for Single Executable Application (SEA) assets. + * Assets are accessed via sea.getAsset() binding. + */ +class SEAProvider extends VirtualProvider { + /** + * @param {object} [options] Options + */ + constructor(options = {}) { + super(); + + // Lazy-load SEA bindings + const { isSea, getAsset, getAssetKeys } = internalBinding('sea'); + + if (!isSea()) { + throw new ERR_INVALID_STATE('SEAProvider can only be used in a Single Executable Application'); + } + + this[kGetAsset] = getAsset; + + // Build asset map and derive directory structure + this[kAssets] = new SafeMap(); + this[kDirectories] = new SafeMap(); + + // Root directory always exists + this[kDirectories].set('/', new SafeSet()); + + const keys = getAssetKeys() || []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + // Normalize key to path + const path = StringPrototypeStartsWith(key, '/') ? key : `/${key}`; + this[kAssets].set(path, key); + + // Derive parent directories + const parts = path.split('/').filter(Boolean); + let currentPath = ''; + for (let j = 0; j < parts.length - 1; j++) { + const parentPath = currentPath || '/'; + currentPath = currentPath + '/' + parts[j]; + + if (!this[kDirectories].has(currentPath)) { + this[kDirectories].set(currentPath, new SafeSet()); + } + + // Add this directory to parent's children + const parentChildren = this[kDirectories].get(parentPath); + if (parentChildren) { + parentChildren.add(parts[j]); + } + } + + // Add file to parent directory's children + if (parts.length > 0) { + const fileName = parts[parts.length - 1]; + const parentPath = parts.length === 1 ? '/' : '/' + parts.slice(0, -1).join('/'); + + if (!this[kDirectories].has(parentPath)) { + this[kDirectories].set(parentPath, new SafeSet()); + } + + this[kDirectories].get(parentPath).add(fileName); + } + } + } + + get readonly() { + return true; + } + + get supportsSymlinks() { + return false; + } + + /** + * Normalizes a path. + * @param {string} path The path + * @returns {string} Normalized path + */ + _normalizePath(path) { + let normalized = path.replace(/\\/g, '/'); + if (normalized !== '/' && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + return normalized; + } + + /** + * Checks if a path is a file. + * @param {string} path Normalized path + * @returns {boolean} + */ + _isFile(path) { + return this[kAssets].has(path); + } + + /** + * Checks if a path is a directory. + * @param {string} path Normalized path + * @returns {boolean} + */ + _isDirectory(path) { + return this[kDirectories].has(path); + } + + /** + * Gets the asset content. + * @param {string} path Normalized path + * @returns {Buffer} + */ + _getAssetContent(path) { + const key = this[kAssets].get(path); + if (!key) { + throw createENOENT('open', path); + } + const content = this[kGetAsset](key); + return Buffer.from(content); + } + + openSync(path, flags, mode) { + // Only allow read modes + if (flags !== 'r') { + throw createEROFS('open', path); + } + + const normalized = this._normalizePath(path); + + if (this._isDirectory(normalized)) { + throw createEISDIR('open', path); + } + + if (!this._isFile(normalized)) { + throw createENOENT('open', path); + } + + const content = this._getAssetContent(normalized); + const getStats = () => createFileStats(content.length, { mode: 0o444 }); + + return new SEAFileHandle(normalized, content, getStats); + } + + async open(path, flags, mode) { + return this.openSync(path, flags, mode); + } + + statSync(path, options) { + const normalized = this._normalizePath(path); + + if (this._isDirectory(normalized)) { + return createDirectoryStats({ mode: 0o555 }); + } + + if (this._isFile(normalized)) { + const content = this._getAssetContent(normalized); + return createFileStats(content.length, { mode: 0o444 }); + } + + throw createENOENT('stat', path); + } + + async stat(path, options) { + return this.statSync(path, options); + } + + lstatSync(path, options) { + // No symlinks, same as stat + return this.statSync(path, options); + } + + async lstat(path, options) { + return this.lstatSync(path, options); + } + + readdirSync(path, options) { + const normalized = this._normalizePath(path); + + if (!this._isDirectory(normalized)) { + if (this._isFile(normalized)) { + throw createENOTDIR('scandir', path); + } + throw createENOENT('scandir', path); + } + + const children = this[kDirectories].get(normalized); + const names = [...children]; + + if (options?.withFileTypes) { + const dirents = []; + for (const name of names) { + const childPath = normalized === '/' ? `/${name}` : `${normalized}/${name}`; + let type; + if (this._isDirectory(childPath)) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; + } + ArrayPrototypePush(dirents, new Dirent(name, type, normalized)); + } + return dirents; + } + + return names; + } + + async readdir(path, options) { + return this.readdirSync(path, options); + } + + mkdirSync(path, options) { + throw createEROFS('mkdir', path); + } + + async mkdir(path, options) { + throw createEROFS('mkdir', path); + } + + rmdirSync(path) { + throw createEROFS('rmdir', path); + } + + async rmdir(path) { + throw createEROFS('rmdir', path); + } + + unlinkSync(path) { + throw createEROFS('unlink', path); + } + + async unlink(path) { + throw createEROFS('unlink', path); + } + + renameSync(oldPath, newPath) { + throw createEROFS('rename', oldPath); + } + + async rename(oldPath, newPath) { + throw createEROFS('rename', oldPath); + } + + readFileSync(path, options) { + const normalized = this._normalizePath(path); + + if (this._isDirectory(normalized)) { + throw createEISDIR('read', path); + } + + if (!this._isFile(normalized)) { + throw createENOENT('open', path); + } + + const content = this._getAssetContent(normalized); + + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding) { + return content.toString(encoding); + } + return content; + } + + async readFile(path, options) { + return this.readFileSync(path, options); + } + + writeFileSync(path, data, options) { + throw createEROFS('open', path); + } + + async writeFile(path, data, options) { + throw createEROFS('open', path); + } + + appendFileSync(path, data, options) { + throw createEROFS('open', path); + } + + async appendFile(path, data, options) { + throw createEROFS('open', path); + } + + copyFileSync(src, dest, mode) { + throw createEROFS('copyfile', dest); + } + + async copyFile(src, dest, mode) { + throw createEROFS('copyfile', dest); + } +} + +module.exports = { + SEAProvider, +}; diff --git a/lib/internal/vfs/router.js b/lib/internal/vfs/router.js index 7f8bc13c8c82..6593e365c054 100644 --- a/lib/internal/vfs/router.js +++ b/lib/internal/vfs/router.js @@ -89,6 +89,10 @@ function isUnderMountPoint(normalizedPath, mountPoint) { if (normalizedPath === mountPoint) { return true; } + // Special case: root mount point - all absolute paths are under it + if (mountPoint === '/') { + return StringPrototypeStartsWith(normalizedPath, '/'); + } // Path must start with mountPoint followed by a slash return StringPrototypeStartsWith(normalizedPath, mountPoint + '/'); } @@ -105,6 +109,10 @@ function getRelativePath(normalizedPath, mountPoint) { if (normalizedPath === mountPoint) { return '/'; } + // Special case: root mount point - the path is already the relative path + if (mountPoint === '/') { + return normalizedPath; + } return StringPrototypeSlice(normalizedPath, mountPoint.length); } @@ -120,6 +128,10 @@ function joinMountPath(mountPoint, relativePath) { if (relativePath === '/') { return mountPoint; } + // Special case: root mount point - the relative path is already the full path + if (mountPoint === '/') { + return relativePath; + } return mountPoint + relativePath; } diff --git a/lib/internal/vfs/sea.js b/lib/internal/vfs/sea.js index f99da97fd8a4..a5a2c80f3c14 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 71f6c234c6b3..a07fd04bceb9 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 000000000000..69160256b106 --- /dev/null +++ b/lib/vfs.js @@ -0,0 +1,58 @@ +'use strict'; + +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { VirtualFileSystem } = require('internal/vfs/file_system'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { MemoryProvider } = require('internal/vfs/providers/memory'); + +// 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 ERR_INVALID_STATE('SEAProvider can only be used in a Single Executable Application'); + } + }; + } + } + return SEAProvider; +} + +/** + * Creates a new VirtualFileSystem instance. + * @param {VirtualProvider} [provider] The provider to use (defaults to MemoryProvider) + * @param {object} [options] Configuration options + * @param {boolean} [options.moduleHooks] Whether to enable require/import hooks (default: true) + * @param {boolean} [options.virtualCwd] Whether to enable virtual working directory + * @returns {VirtualFileSystem} + */ +function create(provider, options) { + // Handle case where first arg is options (no provider) + if (provider !== undefined && provider !== null && + !(provider instanceof VirtualProvider) && + typeof provider === 'object') { + options = provider; + provider = undefined; + } + return new VirtualFileSystem(provider, options); +} + +module.exports = { + create, + VirtualFileSystem, + VirtualProvider, + MemoryProvider, + get SEAProvider() { + return getSEAProvider(); + }, +}; diff --git a/test/parallel/test-vfs-basic.js b/test/parallel/test-vfs-basic.js index 4c5323273f3b..0175e239aa25 100644 --- a/test/parallel/test-vfs-basic.js +++ b/test/parallel/test-vfs-basic.js @@ -8,16 +8,15 @@ const fs = require('fs'); { const myVfs = fs.createVirtual(); assert.ok(myVfs); - assert.strictEqual(typeof myVfs.addFile, 'function'); + assert.strictEqual(typeof myVfs.writeFileSync, 'function'); assert.strictEqual(myVfs.isMounted, false); - assert.strictEqual(myVfs.isOverlay, false); - assert.strictEqual(myVfs.fallthrough, true); } // Test adding and reading a static file { const myVfs = fs.createVirtual(); - myVfs.addFile('/test/file.txt', 'hello world'); + myVfs.mkdirSync('/test', { recursive: true }); + myVfs.writeFileSync('/test/file.txt', 'hello world'); assert.strictEqual(myVfs.existsSync('/test/file.txt'), true); assert.strictEqual(myVfs.existsSync('/test'), true); @@ -35,8 +34,8 @@ const fs = require('fs'); // Test statSync { const myVfs = fs.createVirtual(); - myVfs.addFile('/test/file.txt', 'content'); - myVfs.addDirectory('/test/dir'); + myVfs.mkdirSync('/test/dir', { recursive: true }); + myVfs.writeFileSync('/test/file.txt', 'content'); const fileStat = myVfs.statSync('/test/file.txt'); assert.strictEqual(fileStat.isFile(), true); @@ -56,9 +55,9 @@ const fs = require('fs'); // Test readdirSync { const myVfs = fs.createVirtual(); - myVfs.addFile('/dir/a.txt', 'a'); - myVfs.addFile('/dir/b.txt', 'b'); - myVfs.addDirectory('/dir/subdir'); + myVfs.mkdirSync('/dir/subdir', { recursive: true }); + myVfs.writeFileSync('/dir/a.txt', 'a'); + myVfs.writeFileSync('/dir/b.txt', 'b'); const entries = myVfs.readdirSync('/dir'); assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt', 'subdir']); @@ -88,65 +87,27 @@ const fs = require('fs'); }, { code: 'ENOENT' }); } -// Test dynamic file content -{ - const myVfs = fs.createVirtual(); - let counter = 0; - myVfs.addFile('/dynamic.txt', () => { - counter++; - return `count: ${counter}`; - }); - - assert.strictEqual(myVfs.readFileSync('/dynamic.txt', 'utf8'), 'count: 1'); - assert.strictEqual(myVfs.readFileSync('/dynamic.txt', 'utf8'), 'count: 2'); - assert.strictEqual(counter, 2); -} - -// Test dynamic directory -{ - const myVfs = fs.createVirtual(); - let populated = false; - - myVfs.addDirectory('/dynamic', (dir) => { - populated = true; - dir.addFile('generated.txt', 'generated content'); - dir.addDirectory('subdir', (subdir) => { - subdir.addFile('nested.txt', 'nested content'); - }); - }); - - // Directory should not be populated until accessed - assert.strictEqual(populated, false); - - // Access the directory - const entries = myVfs.readdirSync('/dynamic'); - assert.strictEqual(populated, true); - assert.deepStrictEqual(entries.sort(), ['generated.txt', 'subdir']); - - // Read generated file - assert.strictEqual(myVfs.readFileSync('/dynamic/generated.txt', 'utf8'), 'generated content'); - - // Access nested dynamic directory - assert.strictEqual(myVfs.readFileSync('/dynamic/subdir/nested.txt', 'utf8'), 'nested content'); -} - // Test removing entries { const myVfs = fs.createVirtual(); - myVfs.addFile('/test/file.txt', 'content'); + myVfs.mkdirSync('/test', { recursive: true }); + myVfs.writeFileSync('/test/file.txt', 'content'); - assert.strictEqual(myVfs.has('/test/file.txt'), true); - assert.strictEqual(myVfs.remove('/test/file.txt'), true); - assert.strictEqual(myVfs.has('/test/file.txt'), false); + assert.strictEqual(myVfs.existsSync('/test/file.txt'), true); + myVfs.unlinkSync('/test/file.txt'); + assert.strictEqual(myVfs.existsSync('/test/file.txt'), false); - // Cannot remove non-existent entry - assert.strictEqual(myVfs.remove('/nonexistent'), false); + // Unlinking non-existent entry throws ENOENT + assert.throws(() => { + myVfs.unlinkSync('/nonexistent'); + }, { code: 'ENOENT' }); } // Test mount mode { const myVfs = fs.createVirtual(); - myVfs.addFile('/data/file.txt', 'mounted content'); + myVfs.mkdirSync('/data', { recursive: true }); + myVfs.writeFileSync('/data/file.txt', 'mounted content'); assert.strictEqual(myVfs.isMounted, false); myVfs.mount('/app/virtual'); @@ -161,28 +122,11 @@ const fs = require('fs'); assert.strictEqual(myVfs.isMounted, false); } -// Test overlay mode -{ - const myVfs = fs.createVirtual(); - myVfs.addFile('/overlay/file.txt', 'overlay content'); - - assert.strictEqual(myVfs.isOverlay, false); - myVfs.overlay(); - assert.strictEqual(myVfs.isOverlay, true); - - // shouldHandle based on existence - assert.strictEqual(myVfs.shouldHandle('/overlay/file.txt'), true); - assert.strictEqual(myVfs.shouldHandle('/nonexistent'), false); - - myVfs.unmount(); - assert.strictEqual(myVfs.isOverlay, false); -} - // Test internalModuleStat (used by Module._stat) { const myVfs = fs.createVirtual(); - myVfs.addFile('/module.js', 'module.exports = {}'); - myVfs.addDirectory('/dir'); + myVfs.mkdirSync('/dir', { recursive: true }); + myVfs.writeFileSync('/module.js', 'module.exports = {}'); assert.strictEqual(myVfs.internalModuleStat('/module.js'), 0); // file assert.strictEqual(myVfs.internalModuleStat('/dir'), 1); // directory @@ -192,7 +136,7 @@ const fs = require('fs'); // Test reading directory as file throws EISDIR { const myVfs = fs.createVirtual(); - myVfs.addDirectory('/mydir'); + myVfs.mkdirSync('/mydir', { recursive: true }); assert.throws(() => { myVfs.readFileSync('/mydir'); @@ -202,7 +146,8 @@ const fs = require('fs'); // Test realpathSync { const myVfs = fs.createVirtual(); - myVfs.addFile('/test/file.txt', 'content'); + myVfs.mkdirSync('/test', { recursive: true }); + myVfs.writeFileSync('/test/file.txt', 'content'); const realpath = myVfs.realpathSync('/test/file.txt'); assert.strictEqual(realpath, '/test/file.txt'); diff --git a/test/parallel/test-vfs-chdir-worker.js b/test/parallel/test-vfs-chdir-worker.js index 037dbf99829d..e8d0c880984c 100644 --- a/test/parallel/test-vfs-chdir-worker.js +++ b/test/parallel/test-vfs-chdir-worker.js @@ -9,7 +9,7 @@ if (isMainThread) { // Test 1: Verify that VFS setup in main thread doesn't automatically apply to workers { const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); + vfs.mkdirSync('/project', { recursive: true }); vfs.mount('/virtual'); // Set virtual cwd in main thread @@ -76,7 +76,7 @@ if (isMainThread) { } else if (test === 'worker-independent-vfs') { // Set up VFS independently in worker const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/data'); + vfs.mkdirSync('/data', { recursive: true }); vfs.mount('/worker-virtual'); process.chdir('/worker-virtual/data'); @@ -88,9 +88,8 @@ if (isMainThread) { } else if (test === 'worker-create-vfs') { // Test VFS creation and chdir in worker const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); - vfs.addDirectory('/project/src'); - vfs.overlay(); + vfs.mkdirSync('/project/src', { recursive: true }); + vfs.mount('/'); vfs.chdir('/project/src'); diff --git a/test/parallel/test-vfs-chdir.js b/test/parallel/test-vfs-chdir.js index 41924d391efc..fc5ea4c11b7d 100644 --- a/test/parallel/test-vfs-chdir.js +++ b/test/parallel/test-vfs-chdir.js @@ -32,9 +32,8 @@ const fs = require('fs'); // Test basic chdir functionality { const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); - vfs.addDirectory('/project/src'); - vfs.addFile('/project/src/index.js', 'module.exports = "hello";'); + vfs.mkdirSync('/project/src', { recursive: true }); + vfs.writeFileSync('/project/src/index.js', 'module.exports = "hello";'); vfs.mount('/virtual'); // Change to a directory that exists @@ -51,7 +50,7 @@ const fs = require('fs'); // Test chdir with non-existent path throws ENOENT { const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); + vfs.mkdirSync('/project', { recursive: true }); vfs.mount('/virtual'); assert.throws(() => { @@ -64,7 +63,7 @@ const fs = require('fs'); // Test chdir with file path throws ENOTDIR { const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addFile('/file.txt', 'content'); + vfs.writeFileSync('/file.txt', 'content'); vfs.mount('/virtual'); assert.throws(() => { @@ -77,9 +76,8 @@ const fs = require('fs'); // Test resolvePath with virtual cwd { const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); - vfs.addDirectory('/project/src'); - vfs.addFile('/project/src/index.js', 'module.exports = "hello";'); + vfs.mkdirSync('/project/src', { recursive: true }); + vfs.writeFileSync('/project/src/index.js', 'module.exports = "hello";'); vfs.mount('/virtual'); // Before setting cwd, relative paths use real cwd @@ -115,27 +113,10 @@ const fs = require('fs'); vfs.unmount(); } -// Test chdir in overlay mode -{ - const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); - vfs.addDirectory('/project/lib'); - vfs.overlay(); - - vfs.chdir('/project'); - assert.strictEqual(vfs.cwd(), '/project'); - - vfs.chdir('/project/lib'); - assert.strictEqual(vfs.cwd(), '/project/lib'); - - vfs.unmount(); -} - // Test process.chdir() interception { const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); - vfs.addDirectory('/project/src'); + vfs.mkdirSync('/project/src', { recursive: true }); vfs.mount('/virtual'); const originalCwd = process.cwd(); @@ -159,7 +140,7 @@ const fs = require('fs'); // Test process.chdir() to real path falls through { const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); + vfs.mkdirSync('/project', { recursive: true }); vfs.mount('/virtual'); const originalCwd = process.cwd(); @@ -181,7 +162,7 @@ const fs = require('fs'); // Test process.cwd() returns virtual cwd when set { const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); + vfs.mkdirSync('/project', { recursive: true }); vfs.mount('/virtual'); const originalCwd = process.cwd(); @@ -205,7 +186,7 @@ const fs = require('fs'); const originalCwd = process.cwd; const vfs = fs.createVirtual({ virtualCwd: false }); - vfs.addDirectory('/project'); + vfs.mkdirSync('/project', { recursive: true }); vfs.mount('/virtual'); // process.chdir and process.cwd should not be modified @@ -218,7 +199,7 @@ const fs = require('fs'); // Test virtual cwd is reset on unmount { const vfs = fs.createVirtual({ virtualCwd: true }); - vfs.addDirectory('/project'); + vfs.mkdirSync('/project', { recursive: true }); vfs.mount('/virtual'); vfs.chdir('/virtual/project'); diff --git a/test/parallel/test-vfs-fd.js b/test/parallel/test-vfs-fd.js index d46fd8596cb7..9e971a16a340 100644 --- a/test/parallel/test-vfs-fd.js +++ b/test/parallel/test-vfs-fd.js @@ -7,7 +7,7 @@ const fs = require('fs'); // Test openSync and closeSync { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'hello world'); + myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); assert.ok(fd >= 10000, 'VFS fd should be >= 10000'); @@ -26,7 +26,7 @@ const fs = require('fs'); // Test openSync with directory { const myVfs = fs.createVirtual(); - myVfs.addDirectory('/mydir'); + myVfs.mkdirSync('/mydir', { recursive: true }); assert.throws(() => { myVfs.openSync('/mydir'); @@ -45,7 +45,7 @@ const fs = require('fs'); // Test readSync { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'hello world'); + myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); const buffer = Buffer.alloc(5); @@ -60,7 +60,7 @@ const fs = require('fs'); // Test readSync with position tracking { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'hello world'); + myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); const buffer1 = Buffer.alloc(5); @@ -82,7 +82,7 @@ const fs = require('fs'); // Test readSync with explicit position { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'hello world'); + myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); const buffer = Buffer.alloc(5); @@ -98,7 +98,7 @@ const fs = require('fs'); // Test readSync at end of file { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'short'); + myVfs.writeFileSync('/file.txt', 'short'); const fd = myVfs.openSync('/file.txt'); const buffer = Buffer.alloc(10); @@ -123,7 +123,7 @@ const fs = require('fs'); // Test fstatSync { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'hello world'); + myVfs.writeFileSync('/file.txt', 'hello world'); const fd = myVfs.openSync('/file.txt'); const stats = myVfs.fstatSync(fd); @@ -147,7 +147,7 @@ const fs = require('fs'); // Test async open and close { const myVfs = fs.createVirtual(); - myVfs.addFile('/async-file.txt', 'async content'); + myVfs.writeFileSync('/async-file.txt', 'async content'); myVfs.open('/async-file.txt', common.mustCall((err, fd) => { assert.strictEqual(err, null); @@ -181,7 +181,7 @@ const fs = require('fs'); // Test async read { const myVfs = fs.createVirtual(); - myVfs.addFile('/read-test.txt', 'read content'); + myVfs.writeFileSync('/read-test.txt', 'read content'); myVfs.open('/read-test.txt', common.mustCall((err, fd) => { assert.strictEqual(err, null); @@ -201,7 +201,7 @@ const fs = require('fs'); // Test async read with position tracking { const myVfs = fs.createVirtual(); - myVfs.addFile('/track-test.txt', 'ABCDEFGHIJ'); + myVfs.writeFileSync('/track-test.txt', 'ABCDEFGHIJ'); myVfs.open('/track-test.txt', common.mustCall((err, fd) => { assert.strictEqual(err, null); @@ -239,7 +239,7 @@ const fs = require('fs'); // Test async fstat { const myVfs = fs.createVirtual(); - myVfs.addFile('/fstat-test.txt', '12345'); + myVfs.writeFileSync('/fstat-test.txt', '12345'); myVfs.open('/fstat-test.txt', common.mustCall((err, fd) => { assert.strictEqual(err, null); @@ -268,8 +268,8 @@ const fs = require('fs'); const vfs1 = fs.createVirtual(); const vfs2 = fs.createVirtual(); - vfs1.addFile('/file1.txt', 'content1'); - vfs2.addFile('/file2.txt', 'content2'); + vfs1.writeFileSync('/file1.txt', 'content1'); + vfs2.writeFileSync('/file2.txt', 'content2'); const fd1 = vfs1.openSync('/file1.txt'); const fd2 = vfs2.openSync('/file2.txt'); @@ -297,7 +297,7 @@ const fs = require('fs'); // Test multiple opens of same file { const myVfs = fs.createVirtual(); - myVfs.addFile('/multi.txt', 'multi content'); + myVfs.writeFileSync('/multi.txt', 'multi content'); const fd1 = myVfs.openSync('/multi.txt'); const fd2 = myVfs.openSync('/multi.txt'); diff --git a/test/parallel/test-vfs-glob.js b/test/parallel/test-vfs-glob.js index 2ce34c94fd0c..a3a134402846 100644 --- a/test/parallel/test-vfs-glob.js +++ b/test/parallel/test-vfs-glob.js @@ -7,11 +7,12 @@ const fs = require('fs'); // Test globSync with VFS mounted directory { const myVfs = fs.createVirtual(); - myVfs.addFile('/src/index.js', 'export default 1;'); - myVfs.addFile('/src/utils.js', 'export const util = 1;'); - myVfs.addFile('/src/lib/helper.js', 'export const helper = 1;'); - myVfs.addFile('/src/lib/deep/nested.js', 'export const nested = 1;'); - myVfs.addDirectory('/src/empty'); + myVfs.mkdirSync('/src/lib/deep', { recursive: true }); + myVfs.mkdirSync('/src/empty', { recursive: true }); + myVfs.writeFileSync('/src/index.js', 'export default 1;'); + myVfs.writeFileSync('/src/utils.js', 'export const util = 1;'); + myVfs.writeFileSync('/src/lib/helper.js', 'export const helper = 1;'); + myVfs.writeFileSync('/src/lib/deep/nested.js', 'export const nested = 1;'); myVfs.mount('/virtual'); // Test simple glob pattern @@ -37,32 +38,13 @@ const fs = require('fs'); myVfs.unmount(); } -// Test glob with overlay mode -{ - const myVfs = fs.createVirtual(); - myVfs.addFile('/overlay-glob/a.txt', 'a'); - myVfs.addFile('/overlay-glob/b.txt', 'b'); - myVfs.addFile('/overlay-glob/c.md', 'c'); - myVfs.overlay(); - - const txtFiles = fs.globSync('/overlay-glob/*.txt'); - assert.strictEqual(txtFiles.length, 2); - assert.ok(txtFiles.includes('/overlay-glob/a.txt')); - assert.ok(txtFiles.includes('/overlay-glob/b.txt')); - - const mdFiles = fs.globSync('/overlay-glob/*.md'); - assert.strictEqual(mdFiles.length, 1); - assert.ok(mdFiles.includes('/overlay-glob/c.md')); - - myVfs.unmount(); -} - // Test async glob (callback API) with VFS mounted directory { const myVfs = fs.createVirtual(); - myVfs.addFile('/async-src/index.js', 'export default 1;'); - myVfs.addFile('/async-src/utils.js', 'export const util = 1;'); - myVfs.addFile('/async-src/lib/helper.js', 'export const helper = 1;'); + myVfs.mkdirSync('/async-src/lib', { recursive: true }); + myVfs.writeFileSync('/async-src/index.js', 'export default 1;'); + myVfs.writeFileSync('/async-src/utils.js', 'export const util = 1;'); + myVfs.writeFileSync('/async-src/lib/helper.js', 'export const helper = 1;'); myVfs.mount('/async-virtual'); fs.glob('/async-virtual/async-src/*.js', common.mustCall((err, files) => { @@ -87,9 +69,10 @@ const fs = require('fs'); // Test async glob (promise API) with VFS (async () => { const myVfs = fs.createVirtual(); - myVfs.addFile('/promise-src/a.ts', 'const a = 1;'); - myVfs.addFile('/promise-src/b.ts', 'const b = 2;'); - myVfs.addFile('/promise-src/c.js', 'const c = 3;'); + myVfs.mkdirSync('/promise-src', { recursive: true }); + myVfs.writeFileSync('/promise-src/a.ts', 'const a = 1;'); + myVfs.writeFileSync('/promise-src/b.ts', 'const b = 2;'); + myVfs.writeFileSync('/promise-src/c.js', 'const c = 3;'); myVfs.mount('/promise-virtual'); const { glob } = require('node:fs/promises'); @@ -116,9 +99,9 @@ const fs = require('fs'); // Test glob with withFileTypes option { const myVfs = fs.createVirtual(); - myVfs.addFile('/typed/file.txt', 'text'); - myVfs.addDirectory('/typed/subdir'); - myVfs.addFile('/typed/subdir/nested.txt', 'nested'); + myVfs.mkdirSync('/typed/subdir', { recursive: true }); + myVfs.writeFileSync('/typed/file.txt', 'text'); + myVfs.writeFileSync('/typed/subdir/nested.txt', 'nested'); myVfs.mount('/typedvfs'); const entries = fs.globSync('/typedvfs/typed/*', { withFileTypes: true }); @@ -140,9 +123,10 @@ const fs = require('fs'); // Test glob with multiple patterns { const myVfs = fs.createVirtual(); - myVfs.addFile('/multi/a.js', 'a'); - myVfs.addFile('/multi/b.ts', 'b'); - myVfs.addFile('/multi/c.md', 'c'); + myVfs.mkdirSync('/multi', { recursive: true }); + myVfs.writeFileSync('/multi/a.js', 'a'); + myVfs.writeFileSync('/multi/b.ts', 'b'); + myVfs.writeFileSync('/multi/c.md', 'c'); myVfs.mount('/multipat'); const files = fs.globSync(['/multipat/multi/*.js', '/multipat/multi/*.ts']); @@ -156,7 +140,8 @@ const fs = require('fs'); // Test that unmounting stops glob from finding VFS files { const myVfs = fs.createVirtual(); - myVfs.addFile('/unmount-test/file.js', 'content'); + myVfs.mkdirSync('/unmount-test', { recursive: true }); + myVfs.writeFileSync('/unmount-test/file.js', 'content'); myVfs.mount('/unmount-glob'); let files = fs.globSync('/unmount-glob/unmount-test/*.js'); @@ -171,7 +156,8 @@ const fs = require('fs'); // Test glob pattern that doesn't match anything { const myVfs = fs.createVirtual(); - myVfs.addFile('/nomatch/file.txt', 'content'); + myVfs.mkdirSync('/nomatch', { recursive: true }); + myVfs.writeFileSync('/nomatch/file.txt', 'content'); myVfs.mount('/nomatchvfs'); const files = fs.globSync('/nomatchvfs/nomatch/*.nonexistent'); @@ -183,8 +169,9 @@ const fs = require('fs'); // Test cwd option with VFS (relative patterns) { const myVfs = fs.createVirtual(); - myVfs.addFile('/cwd-test/a.js', 'a'); - myVfs.addFile('/cwd-test/b.js', 'b'); + myVfs.mkdirSync('/cwd-test', { recursive: true }); + myVfs.writeFileSync('/cwd-test/a.js', 'a'); + myVfs.writeFileSync('/cwd-test/b.js', 'b'); myVfs.mount('/cwdvfs'); const files = fs.globSync('*.js', { cwd: '/cwdvfs/cwd-test' }); diff --git a/test/parallel/test-vfs-import.mjs b/test/parallel/test-vfs-import.mjs index 7a491e8fe022..cb254c96724e 100644 --- a/test/parallel/test-vfs-import.mjs +++ b/test/parallel/test-vfs-import.mjs @@ -1,15 +1,11 @@ import '../common/index.mjs'; import assert from 'assert'; -import path from 'path'; import fs from 'fs'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Test importing a simple virtual ES module { const myVfs = fs.createVirtual(); - myVfs.addFile('/hello.mjs', 'export const message = "hello from vfs";'); + myVfs.writeFileSync('/hello.mjs', 'export const message = "hello from vfs";'); myVfs.mount('/virtual'); const { message } = await import('/virtual/hello.mjs'); @@ -21,7 +17,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Test importing a virtual module with default export { const myVfs = fs.createVirtual(); - myVfs.addFile('/default.mjs', 'export default { name: "test", value: 42 };'); + myVfs.writeFileSync('/default.mjs', 'export default { name: "test", value: 42 };'); myVfs.mount('/virtual2'); const mod = await import('/virtual2/default.mjs'); @@ -34,8 +30,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Test importing a virtual module that imports another virtual module { const myVfs = fs.createVirtual(); - myVfs.addFile('/utils.mjs', 'export function add(a, b) { return a + b; }'); - myVfs.addFile('/main.mjs', ` + myVfs.writeFileSync('/utils.mjs', 'export function add(a, b) { return a + b; }'); + myVfs.writeFileSync('/main.mjs', ` import { add } from '/virtual3/utils.mjs'; export const result = add(10, 20); `); @@ -50,8 +46,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Test importing with relative paths { const myVfs = fs.createVirtual(); - myVfs.addFile('/lib/helper.mjs', 'export const helper = () => "helped";'); - myVfs.addFile('/lib/index.mjs', ` + myVfs.mkdirSync('/lib', { recursive: true }); + myVfs.writeFileSync('/lib/helper.mjs', 'export const helper = () => "helped";'); + myVfs.writeFileSync('/lib/index.mjs', ` import { helper } from './helper.mjs'; export const output = helper(); `); @@ -66,7 +63,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Test importing JSON from VFS (with import assertion) { const myVfs = fs.createVirtual(); - myVfs.addFile('/data.json', JSON.stringify({ items: [1, 2, 3], enabled: true })); + myVfs.writeFileSync('/data.json', JSON.stringify({ items: [1, 2, 3], enabled: true })); myVfs.mount('/virtual5'); const data = await import('/virtual5/data.json', { with: { type: 'json' } }); @@ -76,25 +73,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); myVfs.unmount(); } -// Test overlay mode with import -{ - const myVfs = fs.createVirtual(); - const testPath = path.join(__dirname, '../fixtures/vfs-test/overlay-module.mjs'); - - // Create a virtual module at a path that doesn't exist on disk - myVfs.addFile(testPath, 'export const overlayValue = "from overlay";'); - myVfs.overlay(); - - const { overlayValue } = await import(testPath); - assert.strictEqual(overlayValue, 'from overlay'); - - myVfs.unmount(); -} - // Test that real modules still work when VFS is mounted { const myVfs = fs.createVirtual(); - myVfs.addFile('/test.mjs', 'export const x = 1;'); + myVfs.writeFileSync('/test.mjs', 'export const x = 1;'); myVfs.mount('/virtual6'); // Import from node: should still work @@ -104,34 +86,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); myVfs.unmount(); } -// Test dynamic content for ESM modules -{ - const myVfs = fs.createVirtual(); - let counter = 0; - - myVfs.addFile('/dynamic.mjs', () => { - counter++; - return `export const count = ${counter};`; - }); - myVfs.mount('/virtual7'); - - // First import - counter becomes 1 - const mod1 = await import('/virtual7/dynamic.mjs'); - assert.strictEqual(mod1.count, 1); - - // ESM modules are cached, so importing again returns the same module - const mod2 = await import('/virtual7/dynamic.mjs'); - assert.strictEqual(mod2.count, 1); - assert.strictEqual(mod1, mod2); - - myVfs.unmount(); -} - // Test mixed CJS and ESM - ESM importing from VFS while CJS also works { const myVfs = fs.createVirtual(); - myVfs.addFile('/esm-module.mjs', 'export const esmValue = "esm";'); - myVfs.addFile('/cjs-module.js', 'module.exports = { cjsValue: "cjs" };'); + myVfs.writeFileSync('/esm-module.mjs', 'export const esmValue = "esm";'); + myVfs.writeFileSync('/cjs-module.js', 'module.exports = { cjsValue: "cjs" };'); myVfs.mount('/virtual8'); const { esmValue } = await import('/virtual8/esm-module.mjs'); diff --git a/test/parallel/test-vfs-promises.js b/test/parallel/test-vfs-promises.js index f021cc8ef26b..bb4d583083e9 100644 --- a/test/parallel/test-vfs-promises.js +++ b/test/parallel/test-vfs-promises.js @@ -7,7 +7,7 @@ const fs = require('fs'); // Test callback-based readFile { const myVfs = fs.createVirtual(); - myVfs.addFile('/test.txt', 'hello world'); + myVfs.writeFileSync('/test.txt', 'hello world'); myVfs.readFile('/test.txt', common.mustCall((err, data) => { assert.strictEqual(err, null); @@ -39,7 +39,7 @@ const fs = require('fs'); // Test callback-based readFile with directory { const myVfs = fs.createVirtual(); - myVfs.addDirectory('/mydir'); + myVfs.mkdirSync('/mydir', { recursive: true }); myVfs.readFile('/mydir', common.mustCall((err, data) => { assert.strictEqual(err.code, 'EISDIR'); @@ -50,8 +50,8 @@ const fs = require('fs'); // Test callback-based stat { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'content'); - myVfs.addDirectory('/dir'); + myVfs.mkdirSync('/dir', { recursive: true }); + myVfs.writeFileSync('/file.txt', 'content'); myVfs.stat('/file.txt', common.mustCall((err, stats) => { assert.strictEqual(err, null); @@ -75,7 +75,7 @@ const fs = require('fs'); // Test callback-based lstat (same as stat for VFS) { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'content'); + myVfs.writeFileSync('/file.txt', 'content'); myVfs.lstat('/file.txt', common.mustCall((err, stats) => { assert.strictEqual(err, null); @@ -86,9 +86,9 @@ const fs = require('fs'); // Test callback-based readdir { const myVfs = fs.createVirtual(); - myVfs.addFile('/dir/file1.txt', 'a'); - myVfs.addFile('/dir/file2.txt', 'b'); - myVfs.addDirectory('/dir/subdir'); + myVfs.mkdirSync('/dir/subdir', { recursive: true }); + myVfs.writeFileSync('/dir/file1.txt', 'a'); + myVfs.writeFileSync('/dir/file2.txt', 'b'); myVfs.readdir('/dir', common.mustCall((err, entries) => { assert.strictEqual(err, null); @@ -122,7 +122,8 @@ const fs = require('fs'); // Test callback-based realpath { const myVfs = fs.createVirtual(); - myVfs.addFile('/path/to/file.txt', 'content'); + myVfs.mkdirSync('/path/to', { recursive: true }); + myVfs.writeFileSync('/path/to/file.txt', 'content'); myVfs.realpath('/path/to/file.txt', common.mustCall((err, resolved) => { assert.strictEqual(err, null); @@ -143,7 +144,7 @@ const fs = require('fs'); // Test callback-based access { const myVfs = fs.createVirtual(); - myVfs.addFile('/accessible.txt', 'content'); + myVfs.writeFileSync('/accessible.txt', 'content'); myVfs.access('/accessible.txt', common.mustCall((err) => { assert.strictEqual(err, null); @@ -154,25 +155,12 @@ const fs = require('fs'); })); } -// Test async dynamic content with callback API -{ - const myVfs = fs.createVirtual(); - myVfs.addFile('/async-dynamic.txt', async () => { - return 'async content'; - }); - - myVfs.readFile('/async-dynamic.txt', 'utf8', common.mustCall((err, data) => { - assert.strictEqual(err, null); - assert.strictEqual(data, 'async content'); - })); -} - // ==================== Promise API Tests ==================== // Test promises.readFile (async () => { const myVfs = fs.createVirtual(); - myVfs.addFile('/promise-test.txt', 'promise content'); + myVfs.writeFileSync('/promise-test.txt', 'promise content'); const bufferData = await myVfs.promises.readFile('/promise-test.txt'); assert.ok(Buffer.isBuffer(bufferData)); @@ -189,7 +177,7 @@ const fs = require('fs'); { code: 'ENOENT' } ); - myVfs.addDirectory('/promisedir'); + myVfs.mkdirSync('/promisedir', { recursive: true }); await assert.rejects( myVfs.promises.readFile('/promisedir'), { code: 'EISDIR' } @@ -199,8 +187,8 @@ const fs = require('fs'); // Test promises.stat (async () => { const myVfs = fs.createVirtual(); - myVfs.addFile('/stat-file.txt', 'hello'); - myVfs.addDirectory('/stat-dir'); + myVfs.mkdirSync('/stat-dir', { recursive: true }); + myVfs.writeFileSync('/stat-file.txt', 'hello'); const fileStats = await myVfs.promises.stat('/stat-file.txt'); assert.strictEqual(fileStats.isFile(), true); @@ -218,7 +206,7 @@ const fs = require('fs'); // Test promises.lstat (async () => { const myVfs = fs.createVirtual(); - myVfs.addFile('/lstat-file.txt', 'content'); + myVfs.writeFileSync('/lstat-file.txt', 'content'); const stats = await myVfs.promises.lstat('/lstat-file.txt'); assert.strictEqual(stats.isFile(), true); @@ -227,9 +215,9 @@ const fs = require('fs'); // Test promises.readdir (async () => { const myVfs = fs.createVirtual(); - myVfs.addFile('/pdir/a.txt', 'a'); - myVfs.addFile('/pdir/b.txt', 'b'); - myVfs.addDirectory('/pdir/sub'); + myVfs.mkdirSync('/pdir/sub', { recursive: true }); + myVfs.writeFileSync('/pdir/a.txt', 'a'); + myVfs.writeFileSync('/pdir/b.txt', 'b'); const names = await myVfs.promises.readdir('/pdir'); assert.deepStrictEqual(names.sort(), ['a.txt', 'b.txt', 'sub']); @@ -253,7 +241,8 @@ const fs = require('fs'); // Test promises.realpath (async () => { const myVfs = fs.createVirtual(); - myVfs.addFile('/real/path/file.txt', 'content'); + myVfs.mkdirSync('/real/path', { recursive: true }); + myVfs.writeFileSync('/real/path/file.txt', 'content'); const resolved = await myVfs.promises.realpath('/real/path/file.txt'); assert.strictEqual(resolved, '/real/path/file.txt'); @@ -270,7 +259,7 @@ const fs = require('fs'); // Test promises.access (async () => { const myVfs = fs.createVirtual(); - myVfs.addFile('/access-test.txt', 'content'); + myVfs.writeFileSync('/access-test.txt', 'content'); await myVfs.promises.access('/access-test.txt'); @@ -279,21 +268,3 @@ const fs = require('fs'); { code: 'ENOENT' } ); })().then(common.mustCall()); - -// Test async dynamic content with promise API -(async () => { - const myVfs = fs.createVirtual(); - let counter = 0; - - myVfs.addFile('/async-counter.txt', async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - counter++; - return `count: ${counter}`; - }); - - const data1 = await myVfs.promises.readFile('/async-counter.txt', 'utf8'); - assert.strictEqual(data1, 'count: 1'); - - const data2 = await myVfs.promises.readFile('/async-counter.txt', 'utf8'); - assert.strictEqual(data2, 'count: 2'); -})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-require.js b/test/parallel/test-vfs-require.js index 0930e785b10f..f343c4d2d565 100644 --- a/test/parallel/test-vfs-require.js +++ b/test/parallel/test-vfs-require.js @@ -2,7 +2,6 @@ require('../common'); const assert = require('assert'); -const path = require('path'); const fs = require('fs'); // Test requiring a simple virtual module @@ -11,7 +10,7 @@ const fs = require('fs'); // External path: /virtual/hello.js { const myVfs = fs.createVirtual(); - myVfs.addFile('/hello.js', 'module.exports = "hello from vfs";'); + myVfs.writeFileSync('/hello.js', 'module.exports = "hello from vfs";'); myVfs.mount('/virtual'); const result = require('/virtual/hello.js'); @@ -23,7 +22,7 @@ const fs = require('fs'); // Test requiring a virtual module that exports an object { const myVfs = fs.createVirtual(); - myVfs.addFile('/config.js', ` + myVfs.writeFileSync('/config.js', ` module.exports = { name: 'test-config', version: '1.0.0', @@ -43,12 +42,12 @@ const fs = require('fs'); // Test requiring a virtual module that requires another virtual module { const myVfs = fs.createVirtual(); - myVfs.addFile('/utils.js', ` + myVfs.writeFileSync('/utils.js', ` module.exports = { add: function(a, b) { return a + b; } }; `); - myVfs.addFile('/main.js', ` + myVfs.writeFileSync('/main.js', ` const utils = require('/virtual3/utils.js'); module.exports = { sum: utils.add(10, 20) @@ -65,7 +64,7 @@ const fs = require('fs'); // Test requiring a JSON file from VFS { const myVfs = fs.createVirtual(); - myVfs.addFile('/data.json', JSON.stringify({ + myVfs.writeFileSync('/data.json', JSON.stringify({ items: [1, 2, 3], enabled: true, })); @@ -81,11 +80,12 @@ const fs = require('fs'); // Test virtual package.json resolution { const myVfs = fs.createVirtual(); - myVfs.addFile('/my-package/package.json', JSON.stringify({ + myVfs.mkdirSync('/my-package', { recursive: true }); + myVfs.writeFileSync('/my-package/package.json', JSON.stringify({ name: 'my-package', main: 'index.js', })); - myVfs.addFile('/my-package/index.js', ` + myVfs.writeFileSync('/my-package/index.js', ` module.exports = { loaded: true }; `); myVfs.mount('/virtual5'); @@ -96,29 +96,10 @@ const fs = require('fs'); myVfs.unmount(); } -// Test overlay mode with require -{ - const myVfs = fs.createVirtual(); - const testPath = path.join(__dirname, '../fixtures/vfs-test/overlay-module.js'); - - // Create a virtual module at a path that doesn't exist on disk - // In overlay mode, we use the full path as the VFS internal path - myVfs.addFile(testPath, 'module.exports = "overlay module";'); - myVfs.overlay(); - - const result = require(testPath); - assert.strictEqual(result, 'overlay module'); - - myVfs.unmount(); - - // Clear from cache so next tests work - delete require.cache[testPath]; -} - // Test that real modules still work when VFS is mounted { const myVfs = fs.createVirtual(); - myVfs.addFile('/test.js', 'module.exports = 1;'); + myVfs.writeFileSync('/test.js', 'module.exports = 1;'); myVfs.mount('/virtual6'); // require('assert') should still work (builtin) @@ -131,36 +112,14 @@ const fs = require('fs'); myVfs.unmount(); } -// Test dynamic content for modules -{ - const myVfs = fs.createVirtual(); - let counter = 0; - - myVfs.addFile('/dynamic.js', () => { - counter++; - return `module.exports = ${counter};`; - }); - myVfs.mount('/virtual7'); - - // First require - counter becomes 1 - let result = require('/virtual7/dynamic.js'); - assert.strictEqual(result, 1); - - // Clear cache and require again - counter becomes 2 - delete require.cache['/virtual7/dynamic.js']; - result = require('/virtual7/dynamic.js'); - assert.strictEqual(result, 2); - - myVfs.unmount(); -} - // Test require with relative paths inside VFS module { const myVfs = fs.createVirtual(); - myVfs.addFile('/lib/helper.js', ` + myVfs.mkdirSync('/lib', { recursive: true }); + myVfs.writeFileSync('/lib/helper.js', ` module.exports = { help: function() { return 'helped'; } }; `); - myVfs.addFile('/lib/index.js', ` + myVfs.writeFileSync('/lib/index.js', ` const helper = require('./helper.js'); module.exports = helper.help(); `); @@ -175,7 +134,7 @@ const fs = require('fs'); // Test fs.readFileSync interception when VFS is active { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'virtual content'); + myVfs.writeFileSync('/file.txt', 'virtual content'); myVfs.mount('/virtual9'); const content = fs.readFileSync('/virtual9/file.txt', 'utf8'); @@ -187,7 +146,7 @@ const fs = require('fs'); // Test that unmounting stops interception { const myVfs = fs.createVirtual(); - myVfs.addFile('/unmount-test.js', 'module.exports = "before unmount";'); + myVfs.writeFileSync('/unmount-test.js', 'module.exports = "before unmount";'); myVfs.mount('/virtual10'); const result = require('/virtual10/unmount-test.js'); diff --git a/test/parallel/test-vfs-streams.js b/test/parallel/test-vfs-streams.js index 294a171c1724..6675caee7521 100644 --- a/test/parallel/test-vfs-streams.js +++ b/test/parallel/test-vfs-streams.js @@ -7,7 +7,7 @@ const fs = require('fs'); // Test basic createReadStream { const myVfs = fs.createVirtual(); - myVfs.addFile('/file.txt', 'hello world'); + myVfs.writeFileSync('/file.txt', 'hello world'); const stream = myVfs.createReadStream('/file.txt'); let data = ''; @@ -32,7 +32,7 @@ const fs = require('fs'); // Test createReadStream with encoding { const myVfs = fs.createVirtual(); - myVfs.addFile('/encoded.txt', 'encoded content'); + myVfs.writeFileSync('/encoded.txt', 'encoded content'); const stream = myVfs.createReadStream('/encoded.txt', { encoding: 'utf8' }); let data = ''; @@ -54,7 +54,7 @@ const fs = require('fs'); // Test createReadStream with start and end { const myVfs = fs.createVirtual(); - myVfs.addFile('/range.txt', '0123456789'); + myVfs.writeFileSync('/range.txt', '0123456789'); const stream = myVfs.createReadStream('/range.txt', { start: 2, @@ -75,7 +75,7 @@ const fs = require('fs'); // Test createReadStream with start only { const myVfs = fs.createVirtual(); - myVfs.addFile('/start.txt', 'abcdefghij'); + myVfs.writeFileSync('/start.txt', 'abcdefghij'); const stream = myVfs.createReadStream('/start.txt', { start: 5 }); let data = ''; @@ -103,7 +103,7 @@ const fs = require('fs'); // Test createReadStream path property { const myVfs = fs.createVirtual(); - myVfs.addFile('/path-test.txt', 'test'); + myVfs.writeFileSync('/path-test.txt', 'test'); const stream = myVfs.createReadStream('/path-test.txt'); assert.strictEqual(stream.path, '/path-test.txt'); @@ -115,7 +115,7 @@ const fs = require('fs'); // Test createReadStream with small highWaterMark { const myVfs = fs.createVirtual(); - myVfs.addFile('/small-hwm.txt', 'AAAABBBBCCCCDDDD'); + myVfs.writeFileSync('/small-hwm.txt', 'AAAABBBBCCCCDDDD'); const stream = myVfs.createReadStream('/small-hwm.txt', { highWaterMark: 4, @@ -136,7 +136,7 @@ const fs = require('fs'); // Test createReadStream destroy { const myVfs = fs.createVirtual(); - myVfs.addFile('/destroy.txt', 'content to destroy'); + myVfs.writeFileSync('/destroy.txt', 'content to destroy'); const stream = myVfs.createReadStream('/destroy.txt'); @@ -151,7 +151,7 @@ const fs = require('fs'); { const myVfs = fs.createVirtual(); const largeContent = 'X'.repeat(100000); - myVfs.addFile('/large.txt', largeContent); + myVfs.writeFileSync('/large.txt', largeContent); const stream = myVfs.createReadStream('/large.txt'); let receivedLength = 0; @@ -165,34 +165,12 @@ const fs = require('fs'); })); } -// Test createReadStream with dynamic content -{ - const myVfs = fs.createVirtual(); - let calls = 0; - myVfs.addFile('/dynamic-stream.txt', () => { - calls++; - return 'dynamic ' + calls; - }); - - const stream = myVfs.createReadStream('/dynamic-stream.txt', { encoding: 'utf8' }); - let data = ''; - - stream.on('data', (chunk) => { - data += chunk; - }); - - stream.on('end', common.mustCall(() => { - assert.strictEqual(data, 'dynamic 1'); - assert.strictEqual(calls, 1); - })); -} - // Test createReadStream pipe to another stream { const myVfs = fs.createVirtual(); const { Writable } = require('stream'); - myVfs.addFile('/pipe-source.txt', 'pipe this content'); + myVfs.writeFileSync('/pipe-source.txt', 'pipe this content'); const stream = myVfs.createReadStream('/pipe-source.txt'); let collected = ''; @@ -214,7 +192,7 @@ const fs = require('fs'); // Test autoClose: false { const myVfs = fs.createVirtual(); - myVfs.addFile('/no-auto-close.txt', 'content'); + myVfs.writeFileSync('/no-auto-close.txt', 'content'); const stream = myVfs.createReadStream('/no-auto-close.txt', { autoClose: false, diff --git a/test/parallel/test-vfs-symlinks.js b/test/parallel/test-vfs-symlinks.js index 2d8b5b4f2145..c68e47a7e872 100644 --- a/test/parallel/test-vfs-symlinks.js +++ b/test/parallel/test-vfs-symlinks.js @@ -7,8 +7,8 @@ const fs = require('fs'); // Test basic symlink creation { const vfs = fs.createVirtual(); - vfs.addFile('/target.txt', 'Hello, World!'); - vfs.addSymlink('/link.txt', '/target.txt'); + vfs.writeFileSync('/target.txt', 'Hello, World!'); + vfs.symlinkSync('/target.txt', '/link.txt'); vfs.mount('/virtual'); // Verify symlink exists @@ -20,8 +20,9 @@ const fs = require('fs'); // Test reading file through symlink { const vfs = fs.createVirtual(); - vfs.addFile('/data/file.txt', 'File content'); - vfs.addSymlink('/shortcut', '/data/file.txt'); + vfs.mkdirSync('/data', { recursive: true }); + vfs.writeFileSync('/data/file.txt', 'File content'); + vfs.symlinkSync('/data/file.txt', '/shortcut'); vfs.mount('/virtual'); const content = vfs.readFileSync('/virtual/shortcut', 'utf8'); @@ -33,8 +34,8 @@ const fs = require('fs'); // Test statSync follows symlinks (returns target's stats) { const vfs = fs.createVirtual(); - vfs.addFile('/real.txt', 'x'.repeat(100)); - vfs.addSymlink('/link.txt', '/real.txt'); + vfs.writeFileSync('/real.txt', 'x'.repeat(100)); + vfs.symlinkSync('/real.txt', '/link.txt'); vfs.mount('/virtual'); const statLink = vfs.statSync('/virtual/link.txt'); @@ -54,8 +55,8 @@ const fs = require('fs'); // Test lstatSync does NOT follow symlinks { const vfs = fs.createVirtual(); - vfs.addFile('/real.txt', 'x'.repeat(100)); - vfs.addSymlink('/link.txt', '/real.txt'); + vfs.writeFileSync('/real.txt', 'x'.repeat(100)); + vfs.symlinkSync('/real.txt', '/link.txt'); vfs.mount('/virtual'); const lstat = vfs.lstatSync('/virtual/link.txt'); @@ -73,8 +74,8 @@ const fs = require('fs'); // Test readlinkSync returns symlink target { const vfs = fs.createVirtual(); - vfs.addFile('/target.txt', 'content'); - vfs.addSymlink('/link.txt', '/target.txt'); + vfs.writeFileSync('/target.txt', 'content'); + vfs.symlinkSync('/target.txt', '/link.txt'); vfs.mount('/virtual'); const target = vfs.readlinkSync('/virtual/link.txt'); @@ -86,7 +87,7 @@ const fs = require('fs'); // Test readlinkSync throws EINVAL for non-symlinks { const vfs = fs.createVirtual(); - vfs.addFile('/file.txt', 'content'); + vfs.writeFileSync('/file.txt', 'content'); vfs.mount('/virtual'); assert.throws(() => { @@ -99,9 +100,9 @@ const fs = require('fs'); // Test symlink to directory { const vfs = fs.createVirtual(); - vfs.addDirectory('/data'); - vfs.addFile('/data/file.txt', 'content'); - vfs.addSymlink('/shortcut', '/data'); + vfs.mkdirSync('/data', { recursive: true }); + vfs.writeFileSync('/data/file.txt', 'content'); + vfs.symlinkSync('/data', '/shortcut'); vfs.mount('/virtual'); // Reading through symlink directory @@ -118,9 +119,9 @@ const fs = require('fs'); // Test relative symlinks { const vfs = fs.createVirtual(); - vfs.addDirectory('/dir'); - vfs.addFile('/dir/file.txt', 'content'); - vfs.addSymlink('/dir/link.txt', 'file.txt'); // Relative target + vfs.mkdirSync('/dir', { recursive: true }); + vfs.writeFileSync('/dir/file.txt', 'content'); + vfs.symlinkSync('file.txt', '/dir/link.txt'); // Relative target vfs.mount('/virtual'); const content = vfs.readFileSync('/virtual/dir/link.txt', 'utf8'); @@ -136,10 +137,10 @@ const fs = require('fs'); // Test symlink chains (symlink pointing to another symlink) { const vfs = fs.createVirtual(); - vfs.addFile('/file.txt', 'chained'); - vfs.addSymlink('/link1', '/file.txt'); - vfs.addSymlink('/link2', '/link1'); - vfs.addSymlink('/link3', '/link2'); + vfs.writeFileSync('/file.txt', 'chained'); + vfs.symlinkSync('/file.txt', '/link1'); + vfs.symlinkSync('/link1', '/link2'); + vfs.symlinkSync('/link2', '/link3'); vfs.mount('/virtual'); // Should resolve through all symlinks @@ -152,8 +153,9 @@ const fs = require('fs'); // Test realpathSync resolves symlinks { const vfs = fs.createVirtual(); - vfs.addFile('/actual/file.txt', 'content'); - vfs.addSymlink('/link', '/actual'); + vfs.mkdirSync('/actual', { recursive: true }); + vfs.writeFileSync('/actual/file.txt', 'content'); + vfs.symlinkSync('/actual', '/link'); vfs.mount('/virtual'); const realpath = vfs.realpathSync('/virtual/link/file.txt'); @@ -165,8 +167,8 @@ const fs = require('fs'); // Test symlink loop detection (ELOOP) { const vfs = fs.createVirtual(); - vfs.addSymlink('/loop1', '/loop2'); - vfs.addSymlink('/loop2', '/loop1'); + vfs.symlinkSync('/loop2', '/loop1'); + vfs.symlinkSync('/loop1', '/loop2'); vfs.mount('/virtual'); // statSync should throw ELOOP @@ -189,9 +191,9 @@ const fs = require('fs'); // Test readdirSync with withFileTypes includes symlinks { const vfs = fs.createVirtual(); - vfs.addFile('/dir/file.txt', 'content'); - vfs.addDirectory('/dir/subdir'); - vfs.addSymlink('/dir/link', '/dir/file.txt'); + vfs.mkdirSync('/dir/subdir', { recursive: true }); + vfs.writeFileSync('/dir/file.txt', 'content'); + vfs.symlinkSync('/dir/file.txt', '/dir/link'); vfs.mount('/virtual'); const entries = vfs.readdirSync('/virtual/dir', { withFileTypes: true }); @@ -207,26 +209,11 @@ const fs = require('fs'); vfs.unmount(); } -// Test symlink in dynamic directory -{ - const vfs = fs.createVirtual(); - vfs.addDirectory('/dynamic', (dir) => { - dir.addFile('file.txt', 'dynamic content'); - dir.addSymlink('link.txt', 'file.txt'); - }); - vfs.mount('/virtual'); - - const content = vfs.readFileSync('/virtual/dynamic/link.txt', 'utf8'); - assert.strictEqual(content, 'dynamic content'); - - vfs.unmount(); -} - // Test async readlink { const vfs = fs.createVirtual(); - vfs.addFile('/target', 'content'); - vfs.addSymlink('/link', '/target'); + vfs.writeFileSync('/target', 'content'); + vfs.symlinkSync('/target', '/link'); vfs.mount('/virtual'); vfs.readlink('/virtual/link', common.mustSucceed((target) => { @@ -238,8 +225,9 @@ const fs = require('fs'); // Test async realpath with symlinks { const vfs = fs.createVirtual(); - vfs.addFile('/real/file.txt', 'content'); - vfs.addSymlink('/link', '/real'); + vfs.mkdirSync('/real', { recursive: true }); + vfs.writeFileSync('/real/file.txt', 'content'); + vfs.symlinkSync('/real', '/link'); vfs.mount('/virtual'); vfs.realpath('/virtual/link/file.txt', common.mustSucceed((resolvedPath) => { @@ -251,8 +239,8 @@ const fs = require('fs'); // Test promises API - stat follows symlinks { const vfs = fs.createVirtual(); - vfs.addFile('/file.txt', 'x'.repeat(50)); - vfs.addSymlink('/link.txt', '/file.txt'); + vfs.writeFileSync('/file.txt', 'x'.repeat(50)); + vfs.symlinkSync('/file.txt', '/link.txt'); vfs.mount('/virtual'); (async () => { @@ -266,8 +254,8 @@ const fs = require('fs'); // Test promises API - lstat does not follow symlinks { const vfs = fs.createVirtual(); - vfs.addFile('/file.txt', 'x'.repeat(50)); - vfs.addSymlink('/link.txt', '/file.txt'); + vfs.writeFileSync('/file.txt', 'x'.repeat(50)); + vfs.symlinkSync('/file.txt', '/link.txt'); vfs.mount('/virtual'); (async () => { @@ -280,8 +268,8 @@ const fs = require('fs'); // Test promises API - readlink { const vfs = fs.createVirtual(); - vfs.addFile('/target', 'content'); - vfs.addSymlink('/link', '/target'); + vfs.writeFileSync('/target', 'content'); + vfs.symlinkSync('/target', '/link'); vfs.mount('/virtual'); (async () => { @@ -294,8 +282,9 @@ const fs = require('fs'); // Test promises API - realpath resolves symlinks { const vfs = fs.createVirtual(); - vfs.addFile('/real/file.txt', 'content'); - vfs.addSymlink('/link', '/real'); + vfs.mkdirSync('/real', { recursive: true }); + vfs.writeFileSync('/real/file.txt', 'content'); + vfs.symlinkSync('/real', '/link'); vfs.mount('/virtual'); (async () => { @@ -308,7 +297,7 @@ const fs = require('fs'); // Test broken symlink (target doesn't exist) { const vfs = fs.createVirtual(); - vfs.addSymlink('/broken', '/nonexistent'); + vfs.symlinkSync('/nonexistent', '/broken'); vfs.mount('/virtual'); // statSync should throw ENOENT for broken symlink @@ -330,9 +319,10 @@ const fs = require('fs'); // Test symlink with parent traversal (..) { const vfs = fs.createVirtual(); - vfs.addFile('/a/file.txt', 'content'); - vfs.addDirectory('/b'); - vfs.addSymlink('/b/link', '../a/file.txt'); + vfs.mkdirSync('/a', { recursive: true }); + vfs.mkdirSync('/b', { recursive: true }); + vfs.writeFileSync('/a/file.txt', 'content'); + vfs.symlinkSync('../a/file.txt', '/b/link'); vfs.mount('/virtual'); const content = vfs.readFileSync('/virtual/b/link', 'utf8');