Skip to content

Commit 7346ef3

Browse files
committed
vfs: address follow-up items from #62328
Security: - Reject overlapping VFS mounts with clear error - Add allowWithPermissionModel opt-in for permission model - RealFSProvider: validate symlink targets, throw EACCES on escape API compatibility: - VirtualFileHandle: add no-op stubs and Symbol.asyncDispose - Intercept fsPromises.open() for VFS paths - Streams: add bytesRead/pending and bytesWritten/pending - VirtualDir: add Symbol.asyncDispose/Symbol.dispose - Stats: use dev=4085 and incrementing ino - Fix VFSStatWatcher listener signature Correctness: - Fix checkClosed to accept syscall parameter - Fix writeFileSync to use isAppend() for ax/ax+ flags - Fix dead ternary in hookProcessCwd - SEAProvider: cache asset sizes, recursive readdir, numeric flags - Streams: use inherited destroyed, EBADF on null fd Architecture: - Cross-VFS rename/copyFile/link throw EXDEV - Clear package.json caches on unmount - Convert readFile async handler to use promises Mock integration: - Make MockFSContext.vfs a private field with getter - Fix parentDir root check for Windows portability - restoreAll() collects errors into AggregateError - Remove obsolete skipped dynamic content test Provider fixes: - MemoryProvider: statSync uses contentProvider for dynamic size - MemoryProvider: recursive readdir follows symlinks - RealFSProvider: add watch/watchFile/unwatchFile support Code quality: - Cap watcher pending events queue at 1024 - Remove redundant destroyed field from streams
1 parent 4f55ebe commit 7346ef3

17 files changed

Lines changed: 839 additions & 155 deletions

File tree

lib/fs.js

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -409,16 +409,7 @@ function readFile(path, options, callback) {
409409
if (h !== null) {
410410
const opts = typeof options === 'function' ? undefined : options;
411411
if (checkAborted(opts?.signal, callback)) return;
412-
try {
413-
const result = h.readFileSync(path, opts);
414-
if (result !== undefined) {
415-
process.nextTick(callback, null, result);
416-
return;
417-
}
418-
} catch (err) {
419-
process.nextTick(callback, err);
420-
return;
421-
}
412+
if (vfsResult(h.readFile(path, opts), callback)) return;
422413
}
423414

424415
options = getOptions(options, { flag: 'r' });

lib/internal/fs/promises.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,11 @@ async function copyFile(src, dest, mode) {
641641
// Note that unlike fs.open() which uses numeric file descriptors,
642642
// fsPromises.open() uses the fs.FileHandle class.
643643
async function open(path, flags, mode) {
644+
const h = vfsState.handlers;
645+
if (h !== null) {
646+
const result = h.open(path, flags, mode);
647+
if (result !== undefined) return result;
648+
}
644649
path = getValidatedPath(path);
645650
const flagsNumber = stringToFlags(flags);
646651
mode = parseFileMode(mode, 'mode', 0o666);

lib/internal/modules/package_json_reader.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,21 @@ function findPackageJSON(specifier, base = 'data:') {
358358
return pkg?.path;
359359
}
360360

361+
/**
362+
* Clears all package.json caches. Called by VFS on unmount to prevent
363+
* stale entries from paths that were resolved while a VFS was mounted.
364+
*/
365+
function clearPackageJSONCache() {
366+
moduleToParentPackageJSONCache.clear();
367+
deserializedPackageJSONCache.clear();
368+
}
369+
361370
module.exports = {
362371
read,
363372
getNearestParentPackageJSON,
364373
getPackageScopeConfig,
365374
getPackageType,
366375
getPackageJSONURL,
367376
findPackageJSON,
377+
clearPackageJSONCache,
368378
};

lib/internal/test_runner/mock/mock.js

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ const {
5151
const { MockTimers } = require('internal/test_runner/mock/mock_timers');
5252
const { Module } = require('internal/modules/cjs/loader');
5353
const { _load, _nodeModulePaths, _resolveFilename, isBuiltin } = Module;
54-
const { dirname, join } = require('path');
54+
const path = require('path');
55+
const { dirname, join } = path;
5556

5657
// Lazy-load VirtualFileSystem to avoid loading VFS code if fs mocking is not used
5758
const lazyVirtualFileSystem = getLazy(
@@ -413,11 +414,7 @@ const { restore: restoreProperty } = MockPropertyContext.prototype;
413414
* Context for mocking the file system using VFS.
414415
*/
415416
class MockFSContext {
416-
/**
417-
* The underlying VirtualFileSystem instance.
418-
* @type {VirtualFileSystem}
419-
*/
420-
vfs;
417+
#vfs;
421418

422419
/**
423420
* The mount prefix for the mock file system.
@@ -426,10 +423,18 @@ class MockFSContext {
426423
prefix;
427424

428425
constructor(vfs, prefix) {
429-
this.vfs = vfs;
426+
this.#vfs = vfs;
430427
this.prefix = prefix;
431428
}
432429

430+
/**
431+
* The underlying VirtualFileSystem instance.
432+
* @type {VirtualFileSystem}
433+
*/
434+
get vfs() {
435+
return this.#vfs;
436+
}
437+
433438
/**
434439
* Adds a file to the mock file system.
435440
* @param {string} filePath - The path of the file (relative to prefix).
@@ -438,10 +443,11 @@ class MockFSContext {
438443
addFile(filePath, content) {
439444
const fullPath = join(this.prefix, filePath);
440445
const parentDir = dirname(fullPath);
441-
if (parentDir !== '/') {
442-
this.vfs.mkdirSync(parentDir, { __proto__: null, recursive: true });
446+
const { root } = path.parse(parentDir);
447+
if (parentDir !== root) {
448+
this.#vfs.mkdirSync(parentDir, { __proto__: null, recursive: true });
443449
}
444-
this.vfs.writeFileSync(fullPath, content);
450+
this.#vfs.writeFileSync(fullPath, content);
445451
}
446452

447453
/**
@@ -450,7 +456,7 @@ class MockFSContext {
450456
*/
451457
addDirectory(dirPath) {
452458
const fullPath = join(this.prefix, dirPath);
453-
this.vfs.mkdirSync(fullPath, { __proto__: null, recursive: true });
459+
this.#vfs.mkdirSync(fullPath, { __proto__: null, recursive: true });
454460
}
455461

456462
/**
@@ -460,14 +466,14 @@ class MockFSContext {
460466
*/
461467
existsSync(path) {
462468
const fullPath = join(this.prefix, path);
463-
return this.vfs.existsSync(fullPath);
469+
return this.#vfs.existsSync(fullPath);
464470
}
465471

466472
/**
467473
* Restores the file system to its original state.
468474
*/
469475
restore() {
470-
this.vfs.unmount();
476+
this.#vfs.unmount();
471477
}
472478
}
473479

@@ -819,7 +825,8 @@ class MockTracker {
819825
const filePath = paths[i];
820826
const content = files[filePath];
821827
const parentDir = dirname(filePath);
822-
if (parentDir !== '/') {
828+
const { root } = path.parse(parentDir);
829+
if (parentDir !== root) {
823830
vfs.mkdirSync(parentDir, { __proto__: null, recursive: true });
824831
}
825832
vfs.writeFileSync(filePath, content);
@@ -850,12 +857,20 @@ class MockTracker {
850857

851858
/**
852859
* Restore all mocks created by this MockTracker instance.
860+
* Collects errors and throws AggregateError after all mocks are restored.
853861
*/
854862
restoreAll() {
863+
const errors = [];
855864
for (let i = 0; i < this.#mocks.length; i++) {
856865
const { ctx, restore } = this.#mocks[i];
857-
858-
FunctionPrototypeCall(restore, ctx);
866+
try {
867+
FunctionPrototypeCall(restore, ctx);
868+
} catch (err) {
869+
ArrayPrototypePush(errors, err);
870+
}
871+
}
872+
if (errors.length > 0) {
873+
throw new AggregateError(errors, 'Failed to restore some mocks');
859874
}
860875
}
861876

lib/internal/vfs/dir.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
const {
44
SymbolAsyncIterator,
5+
SymbolAsyncDispose,
6+
SymbolDispose,
57
} = primordials;
68

79
const {
@@ -75,15 +77,31 @@ class VirtualDir {
7577
if (this.#closed) {
7678
throw new ERR_DIR_CLOSED();
7779
}
78-
let entry;
79-
while ((entry = this.readSync()) !== null) {
80-
yield entry;
80+
try {
81+
let entry;
82+
while ((entry = this.readSync()) !== null) {
83+
yield entry;
84+
}
85+
} finally {
86+
if (!this.#closed) {
87+
this.closeSync();
88+
}
8189
}
8290
}
8391

8492
[SymbolAsyncIterator]() {
8593
return this.entries();
8694
}
95+
96+
[SymbolAsyncDispose]() {
97+
return this.close();
98+
}
99+
100+
[SymbolDispose]() {
101+
if (!this.#closed) {
102+
this.closeSync();
103+
}
104+
}
87105
}
88106

89107
module.exports = {

lib/internal/vfs/errors.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const {
1919
UV_EINVAL,
2020
UV_ELOOP,
2121
UV_EACCES,
22+
UV_EXDEV,
2223
} = internalBinding('uv');
2324

2425
/**
@@ -179,6 +180,22 @@ function createEACCES(syscall, path) {
179180
return err;
180181
}
181182

183+
/**
184+
* Creates an EXDEV error for cross-device link operations.
185+
* @param {string} syscall The system call name
186+
* @param {string} path The path
187+
* @returns {Error}
188+
*/
189+
function createEXDEV(syscall, path) {
190+
const err = new UVException({
191+
errno: UV_EXDEV,
192+
syscall,
193+
path,
194+
});
195+
ErrorCaptureStackTrace(err, createEXDEV);
196+
return err;
197+
}
198+
182199
module.exports = {
183200
createENOENT,
184201
createENOTDIR,
@@ -190,4 +207,5 @@ module.exports = {
190207
createEINVAL,
191208
createELOOP,
192209
createEACCES,
210+
createEXDEV,
193211
};

0 commit comments

Comments
 (0)