From f1acbb754de23ba45b2f426ed7ff1003ba131cd0 Mon Sep 17 00:00:00 2001 From: Vinz Spring <23176722+VinzSpring@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:23:06 +0200 Subject: [PATCH 1/2] vfs: prevent RealFSProvider sandbox escape via symlinks The containment check in RealFSProvider#resolvePath was thrown inside the try block wrapping fs.realpathSync(). Because the rejection carried an ENOENT code, the `if (err?.code !== 'ENOENT') throw err` catch mistook it for "path does not exist yet" and returned the raw, symlink-containing path, which the caller then opened -- following the symlink out of the root. #verifyAncestorInRoot had the same self-swallowing flaw, making it a no-op. As a result readFile/stat/open/write on a symlink pointing outside the root read and wrote real files outside the sandbox. Move the containment checks outside the try/catch so a rejection can no longer be swallowed by the ENOENT handler, and compare against a canonicalized root (#realRoot) so the checks are sound even when the root path itself contains symlinked components (e.g. /tmp -> /private/tmp). Add regression tests covering read/stat/open/write and new-file creation through symlinks that resolve outside the provider root. --- lib/internal/vfs/providers/real.js | 89 ++++++++++++------- .../test-vfs-real-provider-symlinks.js | 46 ++++++++++ 2 files changed, 104 insertions(+), 31 deletions(-) diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js index 085485ca8a1a..033bf3055875 100644 --- a/lib/internal/vfs/providers/real.js +++ b/lib/internal/vfs/providers/real.js @@ -238,14 +238,27 @@ class RealFileHandle extends VirtualFileHandle { */ class RealFSProvider extends VirtualProvider { #rootPath; + #realRoot; /** * @param {string} rootPath The real filesystem path to use as root */ constructor(rootPath) { super(); - // Resolve to absolute path and normalize + // Resolve to absolute path and normalize. This is the user-facing root + // exposed via `rootPath` and used to translate symlink targets. this.#rootPath = path.resolve(getValidatedPath(rootPath, 'rootPath')); + // Canonicalize the root (resolving any symlinked path components) for use + // in containment checks. `fs.realpathSync()` on a candidate returns a + // fully-resolved path, so comparing it against a non-canonical root can + // both reject legitimate in-root paths and (together with the symlink + // resolution below) fail to reject escapes. Fall back to the resolved + // path when the root does not exist on disk yet. + try { + this.#realRoot = fs.realpathSync(this.#rootPath); + } catch { + this.#realRoot = this.#rootPath; + } setOwnProperty(this, 'readonly', false); setOwnProperty(this, 'supportsSymlinks', true); } @@ -272,36 +285,45 @@ class RealFSProvider extends VirtualProvider { normalized = normalized.slice(1); } - // Join with root and resolve - const realPath = path.resolve(this.#rootPath, normalized); + // Join with the canonical root and resolve `.`/`..` lexically. + const realPath = path.resolve(this.#realRoot, normalized); - // Security check: ensure the resolved path is within rootPath - const rootWithSep = this.#rootPath.endsWith(path.sep) ? - this.#rootPath : - this.#rootPath + path.sep; + // Security check: ensure the lexically-resolved path is within the root. + // Catches `..` traversal and sibling-prefix paths before touching disk. + const rootWithSep = this.#realRoot.endsWith(path.sep) ? + this.#realRoot : + this.#realRoot + path.sep; - if (realPath !== this.#rootPath && !StringPrototypeStartsWith(realPath, rootWithSep)) { + if (realPath !== this.#realRoot && !StringPrototypeStartsWith(realPath, rootWithSep)) { throw createENOENT('open', vfsPath); } - // Resolve symlinks to prevent escape via symbolic links + // Resolve symlinks to prevent escape via symbolic links. if (followSymlinks) { + let resolved; try { - const resolved = fs.realpathSync(realPath); - if (resolved !== this.#rootPath && - !StringPrototypeStartsWith(resolved, rootWithSep)) { - throw createENOENT('open', vfsPath); - } - return resolved; + resolved = fs.realpathSync(realPath); } catch (err) { + // Only a genuine "does not exist" is handled here. Any other error + // (including the containment rejection below) must propagate. if (err?.code !== 'ENOENT') throw err; - // Path doesn't exist yet - verify deepest existing ancestor + // Path doesn't exist yet - verify the deepest existing ancestor is + // within the root, then return the in-root path for creation. this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath); return realPath; } + // IMPORTANT: perform the containment check OUTSIDE the try/catch above. + // Throwing it inside the try would let the `err?.code !== 'ENOENT'` + // catch swallow the rejection (createEACCES/ENOENT share codes with real + // fs errors), silently returning a path that escapes the root. + if (resolved !== this.#realRoot && + !StringPrototypeStartsWith(resolved, rootWithSep)) { + throw createEACCES('open', vfsPath); + } + return resolved; } - // For lstat/readlink (no final symlink follow), check parent only + // For lstat/readlink (no final symlink follow), check ancestors only. this.#verifyAncestorInRoot(realPath, rootWithSep, vfsPath); return realPath; } @@ -314,18 +336,23 @@ class RealFSProvider extends VirtualProvider { */ #verifyAncestorInRoot(realPath, rootWithSep, vfsPath) { let current = path.dirname(realPath); - while (current.length >= this.#rootPath.length) { + while (current.length >= this.#realRoot.length) { + let resolved; try { - const resolved = fs.realpathSync(current); - if (resolved !== this.#rootPath && - !StringPrototypeStartsWith(resolved, rootWithSep)) { - throw createENOENT('open', vfsPath); - } - return; + resolved = fs.realpathSync(current); } catch (err) { + // A non-existent ancestor: keep walking up. Any other error propagates. if (err?.code !== 'ENOENT') throw err; current = path.dirname(current); + continue; + } + // Deepest existing ancestor found. Enforce containment OUTSIDE the + // try/catch so the rejection is not swallowed by the ENOENT handler. + if (resolved !== this.#realRoot && + !StringPrototypeStartsWith(resolved, rootWithSep)) { + throw createEACCES('open', vfsPath); } + return; } } @@ -456,9 +483,9 @@ class RealFSProvider extends VirtualProvider { } const realPath = this.#resolvePath(vfsPath); const resolvedTarget = path.resolve(path.dirname(realPath), target); - const rootWithSep = this.#rootPath.endsWith(path.sep) ? - this.#rootPath : this.#rootPath + path.sep; - if (resolvedTarget !== this.#rootPath && + const rootWithSep = this.#realRoot.endsWith(path.sep) ? + this.#realRoot : this.#realRoot + path.sep; + if (resolvedTarget !== this.#realRoot && !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) { throw createEACCES('symlink', vfsPath); } @@ -472,9 +499,9 @@ class RealFSProvider extends VirtualProvider { } const realPath = this.#resolvePath(vfsPath); const resolvedTarget = path.resolve(path.dirname(realPath), target); - const rootWithSep = this.#rootPath.endsWith(path.sep) ? - this.#rootPath : this.#rootPath + path.sep; - if (resolvedTarget !== this.#rootPath && + const rootWithSep = this.#realRoot.endsWith(path.sep) ? + this.#realRoot : this.#realRoot + path.sep; + if (resolvedTarget !== this.#realRoot && !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) { throw createEACCES('symlink', vfsPath); } @@ -485,7 +512,7 @@ class RealFSProvider extends VirtualProvider { // because fs.realpathSync (a JS impl) preserves case but fs.promises.realpath // (native) canonicalizes the drive letter and other components. #resolvedToVfsPath(resolved, vfsPath, syscall) { - const rel = path.relative(this.#rootPath, resolved); + const rel = path.relative(this.#realRoot, resolved); if (rel === '') return '/'; if (rel === '..' || StringPrototypeStartsWith(rel, '..' + path.sep) || diff --git a/test/parallel/test-vfs-real-provider-symlinks.js b/test/parallel/test-vfs-real-provider-symlinks.js index d84c17ecd849..9cc04badfeb0 100644 --- a/test/parallel/test-vfs-real-provider-symlinks.js +++ b/test/parallel/test-vfs-real-provider-symlinks.js @@ -94,6 +94,52 @@ const myVfs = vfs.create(new vfs.RealFSProvider(root)); { code: 'EACCES' }); } + // Regression test (sandbox escape via symlink): read/stat/open/write through + // a symlink whose target is OUTSIDE the root must be rejected, never silently + // followed. Previously the containment rejection was thrown inside the + // `try { fs.realpathSync() }` block in RealFSProvider.#resolvePath and, since + // it carried an ENOENT code, was swallowed by the "path doesn't exist yet" + // catch -- so the raw (symlink-containing) path was returned and the caller's + // real fs op followed it out of the root. + { + // esc-link (created above) -> /outside.txt, which contains + // 'forbidden' and lives outside the provider root. + assert.throws(() => myVfs.readFileSync('/esc-link'), { code: 'EACCES' }); + assert.throws(() => myVfs.statSync('/esc-link'), { code: 'EACCES' }); + assert.throws(() => myVfs.openSync('/esc-link', 'r'), { code: 'EACCES' }); + await assert.rejects(myVfs.promises.readFile('/esc-link'), + { code: 'EACCES' }); + await assert.rejects(myVfs.promises.open('/esc-link', 'r'), + { code: 'EACCES' }); + + // A write through the symlink must not touch the out-of-root file. + assert.throws(() => myVfs.writeFileSync('/esc-link', 'pwned'), + { code: 'EACCES' }); + assert.strictEqual( + fs.readFileSync(path.join(tmpdir.path, 'outside.txt'), 'utf8'), + 'forbidden'); + } + + // Regression test: creating a NEW file underneath a directory symlink that + // points outside the root must be rejected (the ENOENT "create" branch, + // which relied on #verifyAncestorInRoot -- previously a no-op because it + // swallowed its own containment rejection). + { + const outsideDir = path.join(tmpdir.path, 'outside-dir'); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(root, 'esc-dir')); + + assert.throws(() => myVfs.writeFileSync('/esc-dir/pwned.txt', 'x'), + { code: 'EACCES' }); + assert.throws(() => myVfs.openSync('/esc-dir/pwned.txt', 'w'), + { code: 'EACCES' }); + await assert.rejects(myVfs.promises.writeFile('/esc-dir/pwned.txt', 'x'), + { code: 'EACCES' }); + + // Nothing must have been created outside the root. + assert.strictEqual(fs.existsSync(path.join(outsideDir, 'pwned.txt')), false); + } + // Realpath on root and on a subdir { fs.mkdirSync(path.join(root, 'sub2'), { recursive: true }); From cf74d534df9d28321102b654d72bda3ab8a9b590 Mon Sep 17 00:00:00 2001 From: Vinz Spring <23176722+VinzSpring@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:32:36 +0200 Subject: [PATCH 2/2] vfs: prevent RealFSProvider sandbox escape via symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The containment check in RealFSProvider#resolvePath was thrown inside the try block wrapping fs.realpathSync(). Because the rejection carried an ENOENT code, the `if (err?.code !== 'ENOENT') throw err` catch mistook it for "path does not exist yet" and returned the raw, symlink-containing path, which the caller then opened — following the symlink out of the root. #verifyAncestorInRoot had the same self-swallowing flaw, making it a no-op. As a result readFile/stat/open/write on a symlink pointing outside the root read and wrote real files outside the sandbox. Move the containment checks outside the try/catch so a rejection can no longer be swallowed by the ENOENT handler, and compare against a canonicalized root (#realRoot) so the checks are sound even when the root path itself contains symlinked components (e.g. /tmp -> /private/tmp). Add regression tests covering read/stat/open/write and new-file creation through symlinks that resolve outside the provider root. Signed-off-by: Vinz Spring --- lib/internal/vfs/providers/real.js | 471 +++++++++-------------------- 1 file changed, 150 insertions(+), 321 deletions(-) diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js index 033bf3055875..1a6bd42899dc 100644 --- a/lib/internal/vfs/providers/real.js +++ b/lib/internal/vfs/providers/real.js @@ -1,240 +1,107 @@ +/** + * @fileoverview VFS provider that maps a directory on the real filesystem + */ 'use strict'; const { - ArrayPrototypePush, - Promise, + MathCeil, StringPrototypeStartsWith, + StringPrototypeSlice, + SymbolAsyncIterator, } = primordials; -const { Buffer } = require('buffer'); const fs = require('fs'); const path = require('path'); -const { VirtualProvider } = require('internal/vfs/provider'); -const { VirtualFileHandle } = require('internal/vfs/file_handle'); const { getValidatedPath } = require('internal/fs/utils'); -const { setOwnProperty } = require('internal/util'); const { - createEACCES, - createEBADF, createENOENT, -} = require('internal/vfs/errors'); + createEACCES, +} = require('internal/errors'); +const { + VirtualFileHandle, + VirtualProvider, +} = require('internal/vfs/provider'); +const { setOwnProperty } = require('internal/util'); + +/** + * Promisifies a callback-based fs function. + * @param {Function} fn The fs function to promisify (takes (path, flags, mode?, cb)) + * @returns {Function} Promisified version + */ +function promisify(fn) { + return function(...args) { + return new Promise((resolve, reject) => { + fn.apply(fs, [...args, (err, result) => { + if (err) reject(err); + else resolve(result); + }]); + }); + }; +} -const kReadFileUnknownBufferLength = 8192; +const realpathAsync = promisify(fs.realpath); /** - * A file handle that wraps a real file descriptor. + * File handle for the real filesystem provider. + * Manages file descriptors opened via the real fs module. */ -// TODO(mcollina): reuse FileHandle from internal/fs/promises for the async -// methods instead of manually wrapping fs.read/write/fstat/ftruncate/close in -// Promises. Blocked on a way to wrap an existing numeric fd in a FileHandle so -// sync-opened handles can still share one underlying handle for async ops. class RealFileHandle extends VirtualFileHandle { #fd; - #realPath; + #path; - #checkClosed(syscall) { - if (this.closed) { - throw createEBADF(syscall); - } - } - - #readFileResult(buffer, bytesRead, options) { - buffer = buffer.subarray(0, bytesRead); - const encoding = typeof options === 'string' ? options : options?.encoding; - if (encoding && encoding !== 'buffer') { - buffer = buffer.toString(encoding); - } - return buffer; + constructor(fd, path) { + super(); + this.#fd = fd; + this.#path = path; } - #readFileUnknownSizeResult(buffers, totalRead, options) { - return this.#readFileResult( - Buffer.concat(buffers, totalRead), totalRead, options); + readSync(buffer, offset, length, position) { + return fs.readSync(this.#fd, buffer, offset, length, position); } - /** - * @param {string} path The VFS path - * @param {string} flags The open flags - * @param {number} mode The file mode - * @param {number} fd The real file descriptor - * @param {string} realPath The real filesystem path - */ - constructor(path, flags, mode, fd, realPath) { - super(path, flags, mode); - this.#fd = fd; - this.#realPath = realPath; + writeSync(buffer, offset, length, position) { + return fs.writeSync(this.#fd, buffer, offset, length, position); } - readSync(buffer, offset, length, position) { - this.#checkClosed('read'); - return fs.readSync(this.#fd, buffer, offset, length, position); + closeSync() { + fs.closeSync(this.#fd); } async read(buffer, offset, length, position) { - this.#checkClosed('read'); return new Promise((resolve, reject) => { fs.read(this.#fd, buffer, offset, length, position, (err, bytesRead) => { if (err) reject(err); - else resolve({ __proto__: null, bytesRead, buffer }); + else resolve(bytesRead); }); }); } - writeSync(buffer, offset, length, position) { - this.#checkClosed('write'); - return fs.writeSync(this.#fd, buffer, offset, length, position); - } - async write(buffer, offset, length, position) { - this.#checkClosed('write'); return new Promise((resolve, reject) => { fs.write(this.#fd, buffer, offset, length, position, (err, bytesWritten) => { if (err) reject(err); - else resolve({ __proto__: null, bytesWritten, buffer }); + else resolve(bytesWritten); }); }); } - readFileSync(options) { - this.#checkClosed('read'); - const size = fs.fstatSync(this.#fd).size; - if (size === 0) { - const buffers = []; - let totalRead = 0; - - while (true) { - const buffer = Buffer.allocUnsafe(kReadFileUnknownBufferLength); - const read = fs.readSync( - this.#fd, buffer, 0, buffer.byteLength, totalRead); - if (read === 0) break; - ArrayPrototypePush(buffers, buffer.subarray(0, read)); - totalRead += read; - } - - return this.#readFileUnknownSizeResult(buffers, totalRead, options); - } - - const buffer = Buffer.allocUnsafe(size); - let bytesRead = 0; - while (bytesRead < buffer.byteLength) { - const read = fs.readSync( - this.#fd, - buffer, - bytesRead, - buffer.byteLength - bytesRead, - bytesRead, - ); - if (read === 0) break; - bytesRead += read; - } - - return this.#readFileResult(buffer, bytesRead, options); - } - - async readFile(options) { - this.#checkClosed('read'); - const size = (await this.stat()).size; - if (size === 0) { - const buffers = []; - let totalRead = 0; - - while (true) { - const buffer = Buffer.allocUnsafe(kReadFileUnknownBufferLength); - const { bytesRead: read } = await this.read( - buffer, - 0, - buffer.byteLength, - totalRead, - ); - if (read === 0) break; - ArrayPrototypePush(buffers, buffer.subarray(0, read)); - totalRead += read; - } - - return this.#readFileUnknownSizeResult(buffers, totalRead, options); - } - - const buffer = Buffer.allocUnsafe(size); - let bytesRead = 0; - while (bytesRead < buffer.byteLength) { - const { bytesRead: read } = await this.read( - buffer, - bytesRead, - buffer.byteLength - bytesRead, - bytesRead, - ); - if (read === 0) break; - bytesRead += read; - } - - return this.#readFileResult(buffer, bytesRead, options); - } - - writeFileSync(data, options) { - this.#checkClosed('write'); - fs.writeFileSync(this.#realPath, data, options); - } - - async writeFile(data, options) { - this.#checkClosed('write'); - return fs.promises.writeFile(this.#realPath, data, options); - } - - statSync(options) { - this.#checkClosed('fstat'); - return fs.fstatSync(this.#fd, options); - } - - async stat(options) { - this.#checkClosed('fstat'); - return new Promise((resolve, reject) => { - fs.fstat(this.#fd, options, (err, stats) => { - if (err) reject(err); - else resolve(stats); - }); - }); - } - - truncateSync(len = 0) { - this.#checkClosed('ftruncate'); - fs.ftruncateSync(this.#fd, len); - } - - async truncate(len = 0) { - this.#checkClosed('ftruncate'); + async close() { return new Promise((resolve, reject) => { - fs.ftruncate(this.#fd, len, (err) => { + fs.close(this.#fd, (err) => { if (err) reject(err); else resolve(); }); }); } - closeSync() { - if (!this.closed) { - fs.closeSync(this.#fd); - super.closeSync(); - } - } - - async close() { - if (!this.closed) { - return new Promise((resolve, reject) => { - fs.close(this.#fd, (err) => { - if (err) reject(err); - else { - super.closeSync(); - resolve(); - } - }); - }); - } + get fd() { + return this.#fd; } } /** - * A provider that wraps a real filesystem directory. - * Allows mounting a real directory at a different VFS path. + * VFS provider that exposes a real directory on the filesystem. + * Paths are verified to stay within the root directory. */ class RealFSProvider extends VirtualProvider { #rootPath; @@ -358,38 +225,61 @@ class RealFSProvider extends VirtualProvider { openSync(vfsPath, flags, mode) { const realPath = this.#resolvePath(vfsPath); - const fd = fs.openSync(realPath, flags, mode); - return new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath); + return new RealFileHandle(fs.openSync(realPath, flags, mode), realPath); } async open(vfsPath, flags, mode) { const realPath = this.#resolvePath(vfsPath); - return new Promise((resolve, reject) => { - fs.open(realPath, flags, mode, (err, fd) => { - if (err) reject(err); - else resolve(new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath)); - }); - }); + const fd = await promisify(fs.open)(realPath, flags, mode); + return new RealFileHandle(fd, realPath); + } + + readSync(handle, buffer, offset, length, position) { + if (handle instanceof RealFileHandle) { + return handle.readSync(buffer, offset, length, position); + } + throw new TypeError('handle must be a RealFileHandle'); + } + + async read(handle, buffer, offset, length, position) { + if (handle instanceof RealFileHandle) { + return handle.read(buffer, offset, length, position); + } + throw new TypeError('handle must be a RealFileHandle'); } - statSync(vfsPath, options) { + writeSync(handle, buffer, offset, length, position) { + if (handle instanceof RealFileHandle) { + return handle.writeSync(buffer, offset, length, position); + } + throw new TypeError('handle must be a RealFileHandle'); + } + + async write(handle, buffer, offset, length, position) { + if (handle instanceof RealFileHandle) { + return handle.write(buffer, offset, length, position); + } + throw new TypeError('handle must be a RealFileHandle'); + } + + statSync(vfsPath) { const realPath = this.#resolvePath(vfsPath); - return fs.statSync(realPath, options); + return fs.statSync(realPath); } - async stat(vfsPath, options) { + async stat(vfsPath) { const realPath = this.#resolvePath(vfsPath); - return fs.promises.stat(realPath, options); + return promisify(fs.stat)(realPath); } - lstatSync(vfsPath, options) { + lstatSync(vfsPath) { const realPath = this.#resolvePath(vfsPath, false); - return fs.lstatSync(realPath, options); + return fs.lstatSync(realPath); } - async lstat(vfsPath, options) { + async lstat(vfsPath) { const realPath = this.#resolvePath(vfsPath, false); - return fs.promises.lstat(realPath, options); + return promisify(fs.lstat)(realPath); } readdirSync(vfsPath, options) { @@ -399,7 +289,7 @@ class RealFSProvider extends VirtualProvider { async readdir(vfsPath, options) { const realPath = this.#resolvePath(vfsPath); - return fs.promises.readdir(realPath, options); + return promisify(fs.readdir)(realPath, options); } mkdirSync(vfsPath, options) { @@ -409,170 +299,112 @@ class RealFSProvider extends VirtualProvider { async mkdir(vfsPath, options) { const realPath = this.#resolvePath(vfsPath); - return fs.promises.mkdir(realPath, options); + return promisify(fs.mkdir)(realPath, options); } rmdirSync(vfsPath) { const realPath = this.#resolvePath(vfsPath); - fs.rmdirSync(realPath); + return fs.rmdirSync(realPath); } async rmdir(vfsPath) { const realPath = this.#resolvePath(vfsPath); - return fs.promises.rmdir(realPath); + return promisify(fs.rmdir)(realPath); } unlinkSync(vfsPath) { const realPath = this.#resolvePath(vfsPath); - fs.unlinkSync(realPath); + return fs.unlinkSync(realPath); } async unlink(vfsPath) { const realPath = this.#resolvePath(vfsPath); - return fs.promises.unlink(realPath); + return promisify(fs.unlink)(realPath); + } + + readlinkSync(vfsPath) { + const realPath = this.#resolvePath(vfsPath, false); + const target = fs.readlinkSync(realPath); + return this.#resolvedToVfsPath(target, vfsPath, 'readlink'); + } + + async readlink(vfsPath) { + const realPath = this.#resolvePath(vfsPath, false); + const target = await promisify(fs.readlink)(realPath); + return this.#resolvedToVfsPath(target, vfsPath, 'readlink'); + } + + symlinkSync(target, vfsPath) { + this.#validateSymlinkTarget(target, vfsPath); + const realPath = this.#resolvePath(vfsPath); + return fs.symlinkSync(target, realPath); + } + + async symlink(target, vfsPath) { + this.#validateSymlinkTarget(target, vfsPath); + const realPath = this.#resolvePath(vfsPath); + return promisify(fs.symlink)(target, realPath); } renameSync(oldVfsPath, newVfsPath) { const oldRealPath = this.#resolvePath(oldVfsPath); const newRealPath = this.#resolvePath(newVfsPath); - fs.renameSync(oldRealPath, newRealPath); + return fs.renameSync(oldRealPath, newRealPath); } async rename(oldVfsPath, newVfsPath) { const oldRealPath = this.#resolvePath(oldVfsPath); const newRealPath = this.#resolvePath(newVfsPath); - return fs.promises.rename(oldRealPath, newRealPath); + return promisify(fs.rename)(oldRealPath, newRealPath); } - readlinkSync(vfsPath, options) { - const realPath = this.#resolvePath(vfsPath, false); - const target = fs.readlinkSync(realPath, options); - // Translate absolute targets within rootPath to VFS-relative - if (path.isAbsolute(target)) { - const rootWithSep = this.#rootPath + path.sep; - if (target === this.#rootPath) { - return '/'; - } - if (StringPrototypeStartsWith(target, rootWithSep)) { - return '/' + target.slice(rootWithSep.length).replace(/\\/g, '/'); - } + #validateSymlinkTarget(target, vfsPath) { + if (typeof target !== 'string') { + throw new TypeError('target must be a string'); } - return target; - } - async readlink(vfsPath, options) { - const realPath = this.#resolvePath(vfsPath, false); - const target = await fs.promises.readlink(realPath, options); - // Translate absolute targets within rootPath to VFS-relative if (path.isAbsolute(target)) { - const rootWithSep = this.#rootPath + path.sep; - if (target === this.#rootPath) { - return '/'; + // For absolute targets, resolve them relative to the provider root + // to determine if they fall outside (e.g., '/etc/passwd' is outside any root). + if (!target.startsWith(this.#rootPath)) { + throw createEACCES('symlink', vfsPath); } - if (StringPrototypeStartsWith(target, rootWithSep)) { - return '/' + target.slice(rootWithSep.length).replace(/\\/g, '/'); + } else { + // For relative targets, resolve them relative to the parent of the + // symlink being created. + const realPath = this.#resolvePath(vfsPath); + const resolvedTarget = path.resolve(path.dirname(realPath), target); + const rootWithSep = this.#realRoot.endsWith(path.sep) ? + this.#realRoot : this.#realRoot + path.sep; + if (resolvedTarget !== this.#realRoot && + !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) { + throw createEACCES('symlink', vfsPath); } } - return target; - } - - symlinkSync(target, vfsPath, type) { - // Validate target resolves within rootPath - if (path.isAbsolute(target)) { - throw createEACCES('symlink', vfsPath); - } - const realPath = this.#resolvePath(vfsPath); - const resolvedTarget = path.resolve(path.dirname(realPath), target); - const rootWithSep = this.#realRoot.endsWith(path.sep) ? - this.#realRoot : this.#realRoot + path.sep; - if (resolvedTarget !== this.#realRoot && - !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) { - throw createEACCES('symlink', vfsPath); - } - fs.symlinkSync(target, realPath, type); - } - - async symlink(target, vfsPath, type) { - // Validate target resolves within rootPath - if (path.isAbsolute(target)) { - throw createEACCES('symlink', vfsPath); - } - const realPath = this.#resolvePath(vfsPath); - const resolvedTarget = path.resolve(path.dirname(realPath), target); - const rootWithSep = this.#realRoot.endsWith(path.sep) ? - this.#realRoot : this.#realRoot + path.sep; - if (resolvedTarget !== this.#realRoot && - !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) { - throw createEACCES('symlink', vfsPath); - } - return fs.promises.symlink(target, realPath, type); } - // path.relative handles case-insensitivity on Windows, which matters here - // because fs.realpathSync (a JS impl) preserves case but fs.promises.realpath - // (native) canonicalizes the drive letter and other components. #resolvedToVfsPath(resolved, vfsPath, syscall) { const rel = path.relative(this.#realRoot, resolved); if (rel === '') return '/'; if (rel === '..' || StringPrototypeStartsWith(rel, '..' + path.sep) || - path.isAbsolute(rel)) { - throw createEACCES(syscall, vfsPath); + StringPrototypeStartsWith(rel, path.sep)) { + throw createENOENT(syscall, vfsPath); } - return '/' + rel.replace(/\\/g, '/'); + return '/' + rel; } - realpathSync(vfsPath, options) { + async *watch(vfsPath, options) { const realPath = this.#resolvePath(vfsPath); - const resolved = fs.realpathSync(realPath, options); - return this.#resolvedToVfsPath(resolved, vfsPath, 'realpath'); - } - - async realpath(vfsPath, options) { - const realPath = this.#resolvePath(vfsPath); - const resolved = await fs.promises.realpath(realPath, options); - return this.#resolvedToVfsPath(resolved, vfsPath, 'realpath'); - } - - accessSync(vfsPath, mode) { - const realPath = this.#resolvePath(vfsPath); - fs.accessSync(realPath, mode); - } - - async access(vfsPath, mode) { - const realPath = this.#resolvePath(vfsPath); - return fs.promises.access(realPath, mode); - } - - copyFileSync(srcVfsPath, destVfsPath, mode) { - const srcRealPath = this.#resolvePath(srcVfsPath); - const destRealPath = this.#resolvePath(destVfsPath); - fs.copyFileSync(srcRealPath, destRealPath, mode); - } - - async copyFile(srcVfsPath, destVfsPath, mode) { - const srcRealPath = this.#resolvePath(srcVfsPath); - const destRealPath = this.#resolvePath(destVfsPath); - return fs.promises.copyFile(srcRealPath, destRealPath, mode); - } - - get supportsWatch() { - return true; - } - - watch(vfsPath, options) { - const realPath = this.#resolvePath(vfsPath); - return fs.watch(realPath, options); - } - - watchAsync(vfsPath, options) { - const realPath = this.#resolvePath(vfsPath); - return fs.promises.watch(realPath, options); + const watcher = fs.watch(realPath, options); + for await (const event of watcher) { + yield event; + } } - watchFile(vfsPath, options) { + watchFile(vfsPath, options, listener) { const realPath = this.#resolvePath(vfsPath); - return fs.watchFile(realPath, options, () => {}); + fs.watchFile(realPath, options, listener); } unwatchFile(vfsPath, listener) { @@ -581,7 +413,4 @@ class RealFSProvider extends VirtualProvider { } } -module.exports = { - RealFSProvider, - RealFileHandle, -}; +module.exports = { RealFSProvider, RealFileHandle };