diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js index df9bd00ac1adc4..ec8a4492934ad7 100644 --- a/lib/internal/vfs/providers/real.js +++ b/lib/internal/vfs/providers/real.js @@ -1,18 +1,18 @@ +/** + * @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 { ERR_METHOD_NOT_IMPLEMENTED, } = require('internal/errors').codes; @@ -20,235 +20,117 @@ const { createEACCES, createEBADF, createENOENT, -} = require('internal/vfs/errors'); + createEACCES, +} = require('internal/errors'); +const { + VirtualFileHandle, + VirtualProvider, +} = require('internal/vfs/provider'); +const { setOwnProperty } = require('internal/util'); -const kReadFileUnknownBufferLength = 8192; +/** + * 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 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; - - #checkClosed(syscall) { - if (this.closed) { - throw createEBADF(syscall); - } - } + #path; - #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 }); - }); - }); - } - - 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); + else resolve(bytesWritten); }); }); } - 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; + #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); } @@ -275,36 +157,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; } @@ -317,55 +208,83 @@ 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; } } 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) { @@ -375,7 +294,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) { @@ -385,120 +304,102 @@ 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.#rootPath.endsWith(path.sep) ? - this.#rootPath : this.#rootPath + path.sep; - if (resolvedTarget !== this.#rootPath && - !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) { - throw createEACCES('symlink', vfsPath); - } - fs.symlinkSync(target, realPath, type); - } - - async symlink(target, vfsPath, type) { - // Validate target resolves within rootPath - if (path.isAbsolute(target)) { - throw createEACCES('symlink', vfsPath); - } - const realPath = this.#resolvePath(vfsPath); - const resolvedTarget = path.resolve(path.dirname(realPath), target); - const rootWithSep = this.#rootPath.endsWith(path.sep) ? - this.#rootPath : this.#rootPath + path.sep; - if (resolvedTarget !== this.#rootPath && - !StringPrototypeStartsWith(resolvedTarget, rootWithSep)) { - throw createEACCES('symlink', vfsPath); - } - return fs.promises.symlink(target, realPath, type); } - // 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.#rootPath, resolved); + 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'); @@ -554,9 +455,9 @@ class RealFSProvider extends VirtualProvider { return fs.promises.watch(realPath, options); } - 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) { @@ -565,7 +466,4 @@ class RealFSProvider extends VirtualProvider { } } -module.exports = { - RealFSProvider, - RealFileHandle, -}; +module.exports = { RealFSProvider, RealFileHandle }; diff --git a/test/parallel/test-vfs-real-provider-symlinks.js b/test/parallel/test-vfs-real-provider-symlinks.js index d84c17ecd8490d..9cc04badfeb0e6 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 });