Skip to content

Virtual File System Follow Ups #62328

Description

@jasnell

/cc @mcollina

With The Virtual File System PR getting close to landing, there are a number of areas / issues to follow-up on. Flagging here in a new issue to prevent overloading the PR discussion.

Code Review: PR #61478 — Virtual File System for Node.js

  1. Security & Permission Model
  2. API Compatibility Gaps
  3. Correctness Bugs
  4. Windows Path Handling
  5. Performance Concerns
  6. Architecture & Design
  7. Test Runner Mock Integration
  8. Provider-Specific Issues
  9. Test Coverage Gaps
  10. Code Quality & Cleanup

1. Security & Permission Model

We need to decide if VFS should participate in the permissions model and how.

1.1 VFS interception bypasses the Node.js permission model — CRITICAL

Disposition: follow-up

In lib/fs.js, VFS interception happens before the permission model
check in every intercepted function. Pattern (repeated 96+ times):

function readFileSync(path, options) {
  const h = vfsState.handlers;
  if (h !== null) {
    const result = h.readFileSync(path, options);
    if (result !== undefined) return result;  // ← returns BEFORE permission check
  }
  // permission check is down here
  if (permission.isEnabled() && !permission.has('fs.read', path)) { ... }
}

When --experimental-permission is active, a VFS mount can serve
content for any path without triggering the permission gate. This is
the most significant security issue in the PR.

Recommendation: At minimum, when permission.isEnabled(), the
VFS handler path should still check permission.has('fs.read', ...) /
permission.has('fs.write', ...) as appropriate. Alternatively,
disable VFS mounting entirely when the permission model is active
until a proper integration is designed.

1.2 Any loaded code can call mount() and shadow real paths — HIGH

Disposition: follow-up

require('node:vfs') is a public module with no gating. Any npm
dependency can create a VFS, write arbitrary content, and mount('/')
to intercept every fs call process-wide. There are no restrictions
on mount paths — /etc, /usr, node_modules, or / itself are
all valid targets.

Combined with overlay mode, this enables targeted, nearly undetectable
interception of specific files (e.g., .env, credentials, SSH keys)
while passing all other reads through to the real filesystem.

This was raised by @Qard in
#61478 (comment)
and the vfs-mount event was removed without a replacement gate.

Recommendation: Consider:

  • Requiring --experimental-vfs flag to enable the module
  • Emitting an observable event on mount/unmount for security tooling
  • Restricting mount paths (blocklist for system-critical paths, or allowlist)

1.3 Overlapping mounts have undefined behavior — MEDIUM

Disposition: follow-up

activeVFSList in setup.js is iterated linearly. First-registered
VFS wins. If VFS-A mounts at /app and VFS-B at /app/src, reads
to /app/src/file.js hit VFS-A. In mounted (non-overlay) mode,
VFS-A returns ENOENT without consulting VFS-B. No conflict detection
or warning exists.

Recommendation: registerVFS() should detect and warn (or reject)
overlapping mount points.

1.4 RealFSProvider symlink target not validated — MEDIUM

Disposition: follow-up

providers/real.js:315-318 — symlinkSync(target, vfsPath, type)
validates vfsPath via #resolvePath() but passes target through
to fs.symlinkSync without validation. A user can create a symlink
inside the VFS root pointing to any path on the real filesystem,
enabling a jail escape.

Similarly, readlinkSync may return absolute real-filesystem paths
without translating them into VFS coordinates, leaking path info.

And realpathSync silently returns the original VFS path when the
resolved path escapes the root (lines 336-337, 350-351), masking
the escape rather than throwing.


2. API Compatibility Gaps

These are places where VFS objects don't match the behavior of their
real fs counterparts. Code that works with real fs will break in
subtle ways when given VFS equivalents.

2.1 VirtualFileHandle missing ~15 methods — HIGH

Disposition: follow-up

lib/internal/vfs/file_handle.js — VirtualFileHandle is missing
these methods that exist on real fs.promises.FileHandle:

  • chmod(mode), chown(uid, gid), utimes(atime, mtime)
  • datasync(), sync()
  • readv(buffers), writev(buffers)
  • appendFile(data), readLines(), readableWebStream()
  • createReadStream(), createWriteStream()
  • fd property (numeric file descriptor)
  • [Symbol.asyncDispose]()

Additionally, VirtualFileHandle does not extend EventEmitter, so
the 'close' event (available since v15.4.0) doesn't fire. Code
calling filehandle.on('close', ...) will throw.

Recommendation: Add stub methods that throw
ERR_METHOD_NOT_IMPLEMENTED for unimplemented APIs, and extend
EventEmitter.

2.2 fsPromises.open() not intercepted by VFS — HIGH

Disposition: follow-up

lib/internal/fs/promises.js — The open() function goes directly
to binding.openFileHandle() without any VFS check. Any code using
the modern FileHandle API via fsPromises.open() bypasses VFS
entirely. This includes all FileHandle methods (fh.read(),
fh.write(), fh.stat(), fh.close()).

2.3 Streams missing standard properties — HIGH

Disposition: follow-up

lib/internal/vfs/streams.js:

  • VirtualReadStream — missing bytesRead and pending properties
  • VirtualWriteStream — missing bytesWritten, pending properties,
    and explicit close(callback) method

2.4 VirtualDir missing disposal and auto-close — MEDIUM

Disposition: follow-up

lib/internal/vfs/dir.js:

  • Missing [Symbol.asyncDispose]() and [Symbol.dispose]()
  • entries() async iterator doesn't auto-close the directory after
    iteration (real fs.Dir does)
  • read() and close() are async methods that always return a
    Promise, even in callback mode (real fs.Dir returns undefined
    when a callback is provided)

2.5 Stats objects have ino: 0 and dev: 0 for all files — MEDIUM

Disposition: follow-up

lib/internal/vfs/stats.js — Every virtual file gets ino = 0 and
dev = 0. This means:

  • Code that uses stat.ino for identity checks (e.g., detecting hard
    links, circular references in fs.cp) treats all VFS files as the
    same file
  • stat.dev === 0 could collide with real device IDs

Recommendation: Generate unique inode numbers (incrementing
counter) and use a distinctive dev number for VFS files.

2.6 No bigint Stats support — LOW

Disposition: note

The stats factory functions don't handle { bigint: true }. Callers
passing this option get regular Stats without bigint values. Unlikely
to matter for most use cases but worth documenting.

2.7 VFSWatcher / VFSStatWatcher API mismatches — MEDIUM

Disposition: follow-up

lib/internal/vfs/watcher.js:

  • VFSWatcher never emits 'error' event — errors during polling
    are silently swallowed in #getStats() and #getStatsFor()
  • VFSStatWatcher.addListener(listener) and
    removeListener(listener) break the EventEmitter contract — they
    take only one argument instead of (event, listener), shadowing
    the inherited methods with incompatible signatures

3. Correctness Bugs

3.1 Streams stall on destroyed/null fd — HIGH

Disposition: follow-up

lib/internal/vfs/streams.js:

  • VirtualWriteStream._write() (lines 255-258): When
    this.#destroyed || this.#fd === null, the method returns without
    calling callback(). The writable stream will hang indefinitely.
    Must call callback(error) or callback().

  • VirtualReadStream._read() (lines 94-97): Same pattern — returns
    without pushing null or calling destroy(). The readable stream
    stalls.

3.2 #checkClosed() always reports 'read' syscall — MEDIUM

Disposition: follow-up

lib/internal/vfs/file_handle.js — Both VirtualFileHandle and
MemoryFileHandle implementations of #checkClosed() always throw
createEBADF('read'), even when called from write, truncate, or stat
operations. The error message will be misleading.

3.3 writeFileSync misses 'ax' / 'ax+' append flags — MEDIUM

Disposition: follow-up

lib/internal/vfs/file_handle.js:508 — The append-mode check only
handles 'a' and 'a+', missing 'ax' and 'ax+'. Data written
via writeFileSync with flags: 'ax' will replace content instead
of appending. The #isAppend() method already handles all four
variants and should be used instead.

3.4 Dead ternary in #hookProcessCwd — LOW

Disposition: follow-up

lib/internal/vfs/file_system.js:282-283:

const normalized = isAbsolute(directory) ?
  resolvePath(directory) :
  resolvePath(directory);

Both branches are identical. Likely a leftover from a refactor.

3.5 SEAProvider.statSync reads full asset to get size — MEDIUM

Disposition: follow-up

lib/internal/vfs/providers/sea.js:304 — statSync calls
this.#getAssetContent(normalized) which copies the entire asset into
a new Buffer just to measure .length. For large embedded assets,
this is wasteful. A size-only path or caching would help.

3.6 SEAProvider doesn't handle recursive readdir — MEDIUM

Disposition: follow-up

providers/sea.js — readdirSync does not handle { recursive: true }.
Called with that option, it silently returns only immediate children.

3.7 SEAProvider.openSync only accepts string flag 'r' — LOW

Disposition: follow-up

providers/sea.js:273 — Rejects numeric flags like O_RDONLY = 0.
The MemoryProvider handles this via normalizeFlags() but
SEAProvider does not.

3.8 Shared mutable statsArray is fragile — MEDIUM

Disposition: note

lib/internal/vfs/stats.js:27 — A module-level Float64Array(18)
singleton is reused across all stats creation calls. This is safe only
because getStatsFromBinding() copies synchronously. Any future
refactor that makes it async will introduce silent data corruption.
Should at minimum have a comment asserting this invariant.


4. Windows Path Handling

4.1 Case-insensitive path comparison missing — CRITICAL

Disposition: follow-up (depending on Windows CI status)

All path comparisons in the VFS are case-sensitive:

  • router.js:30 — normalizedPath === mountPoint (strict equality)
  • router.js:40 — StringPrototypeStartsWith(normalizedPath, prefix)
  • memory.js:293 — current.children.get(segment) (Map is case-sensitive)

On Windows, C:\Virtual and c:\virtual and C:\VIRTUAL are the
same path, but the VFS treats them as different. A case-variant access
silently bypasses the VFS and falls through to the real filesystem.

This is both a correctness bug and a security vulnerability if the VFS
is used for sandboxing.

Recommendation: On process.platform === 'win32', normalize paths
to a canonical case before comparison. Provider Map lookups should
also be case-insensitive on Windows.

4.2 UNC paths (\\server\share) unsupported — HIGH

Disposition: follow-up

Zero UNC path handling exists. The MemoryProvider's
#normalizePath converts \\server\share\file →
//server/share/file → /server/share/file (POSIX normalize
collapses //), destroying the UNC prefix. Should either be
explicitly rejected with a clear error or properly supported.

4.3 Virtual CWD uses hardcoded / separator — MEDIUM

Disposition: follow-up

file_system.js:211 — const resolved = \${this[kVirtualCwd]}/${inputPath}`uses a hardcoded/to join paths. On Windows this produces mixed separators likeC:\virtual\subdir/relative/path. While resolvePath()normalizes this on the next line, it's fragile. Should useresolvePath(this[kVirtualCwd], inputPath)` directly.

4.4 findVFSPackageJSON mixed separator check — LOW

Disposition: note

setup.js:352-353 checks both '/node_modules' and
'\\node_modules'. On each platform one branch is dead code. Using
sep + 'node_modules' would be cleaner.


5. Performance Concerns

5.1 Async callback APIs are sync under the hood — HIGH

Disposition: follow-up

Throughout the codebase, callback-based async fs functions delegate to
sync VFS operations wrapped in process.nextTick. This affects:

  • lib/fs.js — All 96+ callback interception points call the
    sync handler and deliver results via nextTick
  • file_system.js — rm(), truncate(), ftruncate(),
    link(), mkdtemp(), opendir() all call their sync counterparts
  • MemoryProvider — Every async method calls the sync counterpart
  • SEAProvider — Same pattern

This means the async API provides zero concurrency benefit and blocks
the event loop. For the in-memory and SEA providers this is pragmatic,
but for RealFSProvider (which has genuine async I/O) or user-created
custom providers, this is a real problem since the lib/fs.js
interception layer forces sync execution regardless of provider
capability.

Recommendation: Document this limitation. Consider allowing the
handlers in setup.js to return a Promise that the lib/fs.js
callback path can handle asynchronously.

5.2 existsSync called in async code paths — MEDIUM

Disposition: follow-up

lib/internal/vfs/setup.js:236 — findVFSWithAsync() calls
vfs.existsSync() (sync!) inside an async function. If a custom
provider's existsSync is expensive (e.g., network-backed), this
blocks the event loop in a nominally async path.

5.3 Linear scan of activeVFSList — LOW

Disposition: note

All findVFSFor* functions iterate the active VFS list linearly.
With many mounted VFS instances this could become slow, though in
practice the list is likely very small (1-3 instances).


6. Architecture & Design

6.1 Maintenance burden of 164+ interception points — HIGH

Disposition: follow-up (design discussion)

The VFS integration adds:

  • ~96 interception blocks in lib/fs.js
  • ~68 interception blocks in lib/internal/fs/promises.js

Every new fs API must remember to add a VFS hook. This is a
significant ongoing maintenance burden and a source of future bugs
when hooks are forgotten.

Recommendation: Consider whether the interception could be
centralized (e.g., a Proxy-based approach, or a single dispatch
function that handles all operations) to reduce the per-function
boilerplate.

6.2 Cross-VFS operations not handled — MEDIUM

Disposition: follow-up

setup.js — renameSync, copyFileSync, linkSync handlers
resolve newPath/dest using the source VFS. If newPath is in a
different VFS mount or outside any VFS, the operation is still
dispatched to the source VFS, which will likely fail in confusing
ways.

6.3 Package.json cache not invalidated on unmount — MEDIUM

Disposition: follow-up

lib/internal/modules/package_json_reader.js caches in
moduleToParentPackageJSONCache and deserializedPackageJSONCache
are never cleared when a VFS is unmounted. Only the CJS _pathCache
and stat cache are cleared (in setup.js:92-93). Stale VFS
package.json data persists after unmount.

6.4 C++ format coupling in package.json serialization — MEDIUM

Disposition: note

setup.js serializePackageJSON() must produce tuples matching the
exact format of the C++ readPackageJSON binding. The VFS does a
parse→serialize→deserialize round-trip (JSON parse the file, extract
fields, stringify certain fields, then the reader deserializes again).
If the C++ format changes, the JS VFS serialization must be updated
too. This is a fragile contract that should be documented with a
cross-reference.

6.5 legacyMainResolve has hardcoded extension arrays — LOW

Disposition: note

setup.js — The VFS implementation of legacyMainResolve hardcodes
extension arrays matching the C++ implementation. If the C++ side
changes the extension mapping, this will silently diverge.


7. Test Runner Mock Integration

7.1 MockFSContext.restore() error prevents other mock cleanup — MEDIUM

Disposition: follow-up

lib/internal/test_runner/mock/mock.js — If vfs.unmount() throws
inside restore(), the error propagates through restoreAll() which
has no try/catch, preventing subsequent mocks from being restored.

7.2 MockFSContext.vfs property exposes full VFS instance — LOW

Disposition: note

The public .vfs property gives test code direct access to the full
VirtualFileSystem instance, allowing unintended operations like
calling mount() at a different prefix. Consider making this a
private field.

7.3 Platform-specific parentDir !== '/' check — LOW

Disposition: follow-up

mock.js:441 — if (parentDir !== '/') doesn't account for Windows
roots like C:\. Should use path.parse() or compare against the
mount prefix root.


8. Provider-Specific Issues

8.1 MemoryProvider: statSync reports size 0 for dynamic files — MEDIUM

Disposition: follow-up

providers/memory.js:408 — When a file has a contentProvider (dynamic
content) and no explicit size, statSync reads entry.content.length
which is the initial empty buffer (Buffer.alloc(0)), reporting size 0.

8.2 MemoryProvider: symlinkSync auto-creates parent dirs — LOW

Disposition: note

providers/memory.js:808 — symlinkSync passes create=true to
#ensureParent, auto-creating intermediate directories. Other
mutating operations pass create=false. This is inconsistent and
could surprise users.

8.3 MemoryProvider: Recursive readdir doesn't follow symlinks — LOW

Disposition: note

providers/memory.js:597 — #readdirRecursive checks
childEntry.isDirectory() directly without following symlinks. This
may differ from real readdir behavior where { recursive: true }
follows symlinks.

8.4 RealFSProvider: Path traversal via symlinks — MEDIUM

Disposition: follow-up

providers/real.js — #resolvePath validates the logical path but
does NOT resolve symlinks before checking. If the real filesystem has
a symlink inside rootPath pointing outside it, operations follow
that symlink and access files outside the jail. A proper chroot would
need fs.realpathSync in #resolvePath or a post-resolution check.

8.5 RealFSProvider: No watch support — LOW

Disposition: note

The real filesystem provider doesn't implement watch/watchFile/
unwatchFile despite wrapping a real filesystem that could easily
support them.


9. Test Coverage Gaps

9.1 Windows tests are minimal — HIGH

Disposition: follow-up

test/parallel/test-vfs-windows.js has only 4 test cases (100 lines).
Missing coverage:

Missing Test Priority
Case-insensitive path matching CRITICAL
UNC paths HIGH
Mixed separators (C:\virtual/subdir\file.txt) HIGH
Path traversal with .. on Windows HIGH
Forward-slash Windows paths (C:/virtual/file.txt) MEDIUM
Overlay mode on Windows MEDIUM
Virtual CWD on Windows MEDIUM
Write operations through fs on Windows MEDIUM

9.2 fsPromises.open() / FileHandle path untested — HIGH

Disposition: follow-up

No tests verify that fsPromises.open() works with VFS paths
(because it doesn't — see §2.2). Tests should be added once this
is supported.

9.3 Overlapping mount behavior untested — MEDIUM

Disposition: follow-up

No tests verify behavior when multiple VFS instances mount at
overlapping paths.

9.4 Permission model interaction untested — MEDIUM

Disposition: follow-up

No tests verify VFS behavior when --experimental-permission is
active.


10. Code Quality & Cleanup

10.1 Watcher unbounded memory growth — HIGH

Disposition: follow-up

lib/internal/vfs/watcher.js:

  • VFSWatchAsyncIterable.#pendingEvents grows without bound if
    the consumer reads slower than events arrive
  • #pendingResolvers grows without bound if next() is called
    many times without events
  • No backpressure mechanism exists

10.2 Redundant #destroyed field in streams — LOW

Disposition: note

Both VirtualReadStream and VirtualWriteStream track #destroyed
manually, but Readable and Writable already have a destroyed
property.

10.3 SEAProvider primordials inconsistency — LOW

Disposition: follow-up

providers/sea.js:

  • Line 226: Uses path.replace() regex instead of
    StringPrototypeReplaceAll from primordials
  • Line 335: Uses spread [...children] instead of ArrayFrom
    from primordials

10.4 VirtualFileHandle private method inaccessible to subclass — LOW

Disposition: note

file_handle.js — #checkClosed() is a private method on the base
class, but private methods aren't inherited. MemoryFileHandle
re-declares its own #checkClosed() at line 261. The duplication
confirms the pattern doesn't work cleanly. Consider making it a
regular method or using the symbol-based pattern already used for
properties.

10.5 file_system.js and setup.js are very large — LOW

Disposition: note

file_system.js (1347 lines) and setup.js (1080 lines) are
substantial, driven by the three API surfaces (sync/callback/promise)
and comprehensive fs handler delegation. The code is functional but
difficult to navigate. Consider splitting in follow-ups.

10.6 VirtualFD range starts at 10,000 — LOW

Disposition: note

lib/internal/vfs/fd.js:18 — Virtual FDs start at 10,000 to avoid
collision with real OS FDs. As noted in a PR comment by @arcanis, this
is low for long-running apps that open/close many files. The fslib
project reserves the upper byte of an i32 instead. No FD reuse or
wraparound exists. Worth revisiting in a follow-up.


Activity

  1. aduh95 commented on Mar 18, 2026

    @aduh95
    Contributor

    I wonder if we should use Matteo's fork to send PRs, I also have a bunch of "refactor requests" in mind that I don't feel like doing on the one PR, given the thread is already too long to follow. That might help grow the confidence on that PR before actually merging it

  2. jasnell commented on Mar 18, 2026

    @jasnell
    MemberAuthor

    I'd say that's up to @mcollina ... personally I'd be just as happy for it to land here and have the follow-up PRs opened and discussed here.

  3. added
    vfsIssues and PRs related to the virtual filesystem subsystem.
    on Mar 18, 2026
  4. mcollina commented on Mar 20, 2026

    @mcollina
    SponsorMember

    @aduh95 I'm applying some refactoring myself as people point things down. Just drop a long comment there, here or in another issue and I'll do them.

  5. aduh95 commented on Mar 20, 2026

    @aduh95
    Contributor

    The thing is, the GitHub UI is basically useless when it comes to reviewing those big PRs, so I've always given up on reviewing the content of the lib/internal/vfs/** files – also the stakes are lower, it can only affect itself, so I feel it's not worth the noise (trust me I can be very noisy)

  6. mcollina commented on Mar 21, 2026

    @mcollina
    SponsorMember

    @jasnell here is a full follow up (AI-gen):

    Status update on follow-ups from #62328

    Here's a summary of what has been addressed so far and what remains open.

    Addressed

    # Item Status
    1.1 VFS bypasses permission model Addressed — registerVFS() throws when permission.isEnabled() unless --allow-fs-vfs runtime flag is set (added to src/node_options.cc in the permission namespace)
    1.3 Overlapping mounts undefined behavior Addressed — registerVFS() detects prefix-overlapping mount points (using /-terminated prefix comparison to avoid false positives like /virtual vs /virtual2) and rejects with ERR_INVALID_STATE
    1.4 RealFSProvider symlink jail escape Addressed — symlinkSync validates target stays within root, readlinkSync translates paths to VFS-relative, realpathSync throws EACCES on escape
    2.1 VirtualFileHandle missing methods Partially addressed — added chmod/chown/utimes/datasync/sync (no-op stubs), readv/writev/appendFile, [Symbol.asyncDispose](), readLines/readableWebStream/createReadStream/createWriteStream (throw ERR_METHOD_NOT_IMPLEMENTED). Still missing: fd property and EventEmitter extension (no 'close' event)
    2.2 fsPromises.open() not intercepted Addressed — separate promisesOpen handler in setup.js returns VirtualFileHandle for fsPromises.open(), while open handler returns numeric FD for callback fs.open()
    2.3 Streams missing properties Addressed — added bytesRead/pending on VirtualReadStream, bytesWritten/pending on VirtualWriteStream
    2.4 VirtualDir missing disposal Addressed — added [Symbol.asyncDispose](), [Symbol.dispose](), entries() auto-close via try/finally
    2.5 Stats ino/dev always 0 Addressed — unique incrementing ino, distinctive dev = 4085 (0xFF9) applied to all three stats factory functions
    2.7 Watcher API mismatches Partially addressed — VFSStatWatcher.addListener/removeListener fixed to (event, listener) signature. Still missing: VFSWatcher does not emit 'error' events from catch blocks
    3.1 Streams stall on destroyed/null fd Addressed — _write() calls callback(createEBADF('write')), _read() calls this.destroy(createEBADF('read'))
    3.2 #checkClosed() always reports 'read' Addressed — parameterized with syscall argument, all call sites pass correct syscall name, both base and subclass #checkClosed(syscall) pass it to createEBADF(syscall)
    3.3 writeFileSync misses 'ax'/'ax+' Addressed — uses #isAppend() instead of manual flag check
    3.4 Dead ternary in #hookProcessCwd Addressed — simplified to single resolvePath(directory)
    3.5 SEAProvider statSync reads full asset Addressed — asset sizes cached in _assetSizes map on first access
    3.6 SEAProvider recursive readdir Addressed — #readdirRecursive() walks subdirectories
    3.7 SEAProvider numeric flags Addressed — openSync() handles numeric 0 (O_RDONLY) inline
    3.8 Shared mutable statsArray Addressed — safety comment added explaining synchronous-copy invariant
    5.1 readFile handler still sync Partially addressed — readFile handler in setup.js converted to async (vfs.promises.readFile), lib/fs.js uses vfsResult() to route promise to callback. Other callback handlers still use sync+nextTick
    6.2 Cross-VFS operations Addressed — checkSameVFS() helper validates rename/copyFile/link src and dest resolve to same VFS; throws EXDEV otherwise
    6.3 Package.json cache not invalidated on unmount Addressed — deregisterVFS() calls clearPackageJSONCache() exported from package_json_reader.js
    7.1 restoreAll() error handling Addressed — each restore() wrapped in try/catch, AggregateError thrown after all mocks restored
    7.2 MockFSContext.vfs exposes full instance Addressed — changed to #vfs private field with read-only getter
    7.3 Platform-specific root check Addressed — uses path.parse(parentDir).root !== parentDir instead of !== '/'
    8.1 MemoryProvider stat size 0 for dynamic files Addressed — #createStats calls entry.getContentSync().length when entry.isDynamic()
    8.2 MemoryProvider symlinkSync auto-creates dirs Addressed — changed to #ensureParent(normalized, false, 'symlink') consistent with other operations
    8.3 MemoryProvider recursive readdir doesn't follow symlinks Addressed — #readdirRecursive resolves symlinks before checking isDirectory()
    8.5 RealFSProvider no watch support Addressed — watch()/watchFile()/unwatchFile() implemented with path translation, supportsWatch = true
    10.1 Watcher unbounded memory growth Addressed — kMaxPendingEvents = 1024 cap on #pendingEvents, oldest events dropped when full
    10.2 Redundant #destroyed in streams Addressed — removed, uses inherited this.destroyed
    10.3 SEAProvider primordials Addressed — uses StringPrototypeReplaceAll, ArrayFrom, StringPrototypeStartsWith from primordials

    Tests added

    • test-vfs-overlapping-mounts.js — overlapping mount rejection, including non-overlapping prefix and root cases (1.3)
    • test-vfs-promises-open.js — fsPromises.open() interception (2.2)
    • test-vfs-stream-properties.js — bytesRead/bytesWritten/pending (2.3)
    • test-vfs-dir-disposal.js — Symbol.asyncDispose/Symbol.dispose (2.4)
    • test-vfs-stats-ino-dev.js — unique ino, distinctive dev (2.5)
    • test-vfs-append-write.js — append mode correctness (3.3)
    • test-vfs-cross-device.js — EXDEV for cross-VFS rename/copyFile/link (6.2)
    • test-vfs-readdir-symlink-recursive.js — recursive readdir follows symlinks (8.3)
    • test-vfs-package-json-cache.js — cache cleared on unmount (6.3)
    • test-vfs-readfile-async.js — readFile uses async handler (5.1)

    Not addressed (requires further work)

    # Item Notes
    1.2 Any loaded code can call mount() Design/policy discussion — needs --experimental-vfs flag or security event
    2.1 (partial) fd property, EventEmitter extension VirtualFileHandle doesn't expose fd numeric property or emit 'close' event
    2.3 (partial) VirtualWriteStream close(callback) Missing explicit close(callback) method
    2.7 (partial) VFSWatcher error events VFSWatcher does not emit 'error' events from catch blocks in #getStats()/#getStatsFor()
    4.1 Case-insensitive path comparison (Windows) Requires Windows-specific path normalization
    4.2 UNC paths unsupported Windows-specific
    4.3 Virtual CWD hardcoded / separator Windows-specific
    4.4 findVFSPackageJSON mixed separator Note only
    5.1 (partial) Other callback APIs still sync Only readFile converted; broader pattern remains
    5.2 existsSync in async code paths findVFSWith() still uses existsSync in paths used by async handlers
    5.3 Linear scan of activeVFSList Note only, unlikely to matter in practice
    6.1 Maintenance burden of 164+ interception points Design discussion
    6.4 C++ format coupling in package.json serialization Note only
    6.5 legacyMainResolve hardcoded extensions Note only
    8.4 RealFSProvider path traversal via real-fs symlinks #resolvePath validates logical path but doesn't resolve real-fs symlinks before checking
    9.1 Windows tests minimal Blocked on Windows path handling (4.x items)
    9.4 Permission model interaction untested Needs subprocess test with --permission flag
    10.4 VirtualFileHandle #checkClosed duplication Note only — inherent to JS private fields
    10.5 file_system.js and setup.js are very large Note only
    10.6 VirtualFD range starts at 10,000 Note only
  7. jimmywarting commented on Apr 4, 2026

    @jimmywarting

    fyi, i'm not really against or for this virtual fs (but it's going to sound like i'm against it - or maybe i'm slightly against it).

    But i just want to raise something that hasn't been addressed in this discussion: I don't think the benefits here justify what this adds to Node.js core.

    The reaction to this PR itself is a signal worth paying attention to: roughly 33% downvotes on a Node.js core feature PR is unusually high. For most uncontroversial additions the opposition is in the low single digits. That level of disagreement from the community suggests the concerns here aren't just noise.

    The use cases already have solutions

    For testing/mocking: A temporary directory in os.tmpdir() with cleanup afterwards is good enough for most cases. It's boring, it works everywhere, zero new APIs to learn.

    For in-memory use cases: Blob and File already exist and cover the simple cases well. They're standardized and work in every runtime.

    For anything more serious: Every major OS has built-in support for mounting virtual and RAM-backed filesystems. You get a real directory that every process sees — native addons, child processes, file descriptors, the permission model, everything just works. Zero lines added to Node.js core.

    For SEA assets specifically: yes, an in-process solution makes sense here since there's no real path to mount. That's the one genuinely novel use case. But that's a much smaller, more focused feature than what this PR is.

    The cost to core is too high

    • 21,645 lines added to Node.js core
    • 164+ manual interception hooks in lib/fs.js and lib/internal/fs/promises.js that every future fs API addition must remember to include
    • The bugs catalogued in this very issue exist because that surface area is nearly impossible to keep complete and correct — it will never be fully done
    • Native addons and child processes still bypass VFS entirely, so it doesn't even fully solve the problem it sets out to solve

    The fragmentation problem

    This is my bigger concern. If Node.js ships its own in-process VFS, Bun and Deno will either ignore it or ship their own incompatible version. We end up with yet another Node-specific API that library authors have to either avoid or special-case per runtime.

    We already have this problem all over the place. Every time Node.js invents a non-web-standard API instead of aligning with or pushing for a web standard, it makes cross-runtime code harder. Blob, File, ReadableStream, OPFS — these exist so that code dealing with files and data doesn't have to care what runtime it's on. A node:vfs module goes in exactly the opposite direction.

    I understand OPFS was considered and rejected — but the answer to "OPFS doesn't fit our needs" should be "let's work with the standards bodies to extend it", not "let's build our own thing in core."

    What I'd suggest instead

    • If ppl need fast / non disk IO, then FUSE or ram disk might be the solution instead.
    • Drop the general-purpose in-process VFS from core
    • embrace OPFS & filesystem access that can make things work the same way across NodeJS and Browsers

    The Node.js ecosystem is already massive. Adding permanent, hard-to-remove APIs to core should clear a much higher bar than this currently does.

    as for my understanding ppl seem to like how Bun handles file IO buy using Blob/Files more than using node:fs when it comes to reading files, i think it's more supperior to do something like await Bun.file(path).arrayBuffer() then any of the alternative solution that exist in node or deno. i certainly for one prefer to use the openAsBlob then using any of the other method that exist on node:fs to handle reading files from the disk. so why should i have to use a virtual fs when i could just simply create virtual files using new Blob() || new File()?

  8. jasnell commented on Apr 4, 2026

    @jasnell
    MemberAuthor

    21,645 lines added to Node.js core

    Folks keep pointing out the size of the PR. Let's break it down.

    Category +lines -lines net files % of additions
    Impl 9,162 75 9,087 38 42.3%
    Test 11,202 0 11,202 82 51.8%
    Doc 1,281 0 1,281 8 5.9%
    Total 21,645 75 21,570 128 100.0%

    A big part of the reason the PR is so large is the test coverage and doc requirements -- a PR won't pass CI without a minimal amount of test coverage.

    By comparison, the existing fs implementation breaks down as:

    Category Lines Files
    JS impl 9,824 13
    C++ impl 6,982 12
    Tests 25,155 359
    Docs 9,075 1
    Benchmarks 2,528 46
    Total 53,564 431

    The crypto module...

    Category Lines Files
    JS impl 11,003 28
    C++ impl 21,827 60
    Tests 16,043 128
    Docs 6,939 1
    Benchmarks 1,394 21
    Total 57,206 238

    The point here is that "the cost to core is too high" really isn't true. The cost is far less than other existing core modules. The only reason it looks big is that it's coming in at once rather than incrementally over a longer period of time.

    The bugs catalogued in this very issue exist because...

    Bugs exist in every subsystem. I wouldn't even consider these to be bugs at all. It's a work in progress and these are outstanding todos.

    Native addons and child processes still bypass VFS entirely

    And that's fine. Native addons have always been special and can bypass pretty much everything. If someone needed to, they could use the permission system to disable native addons.

    If Node.js ships its own in-process VFS, Bun and Deno will either ignore it or ship their own incompatible version

    Or they'll duplicate it as part of their respective Node.js compatibility efforts (which is likely what we would do in cloudflare workers... at least to the extent there is overlap -- we don't have SEA or the same kind of module loader hooks). What other runtimes may or may not do relative to Node.js specific APIs really isn't much of a concern for us here. If this were implementing to a standard spec that would be a different matter.

  9. robertsLando commented on Apr 7, 2026

    @robertsLando
    Contributor

    Worker threads don't inherit VFS mounts (SEA use case)

    While integrating @platformatic/vfs (the userland polyfill of this PR) as the VFS layer for @yao-pkg/pkg SEA mode, we discovered that worker threads don't inherit VFS hooks from the main thread. This affects real-world applications that use libraries like pino (via thread-stream), which spawn workers to handle log transport.

    The problem

    When a SEA binary sets up VFS in the main thread and then a library spawns a Worker with a path inside the VFS mount (e.g., /snapshot/node_modules/thread-stream/lib/worker.js), the worker fails with:

    Error: Cannot find module '/snapshot/node_modules/thread-stream/lib/worker.js'
    

    This happens because:

    1. VFS hooks (both setVfsHandlers at the C++ level and Module.registerHooks at the JS level) are per-Environment (per-isolate)
    2. Worker threads get a fresh Environment with no VFS mounted
    3. node:sea assets ARE process-wide (accessible from any thread), but nothing re-initializes the VFS in the worker

    Current workaround

    We had to monkey-patch the Worker constructor to intercept /snapshot/... paths. For each intercepted worker:

    1. Read the worker file from VFS in the main thread
    2. Prepend a bundled VFS bootstrap (same @platformatic/vfs code, bundled separately by esbuild)
    3. Pass the combined code via new Worker(code, { eval: true })

    This works but is fragile — it requires bundling a separate copy of the VFS module hooks for workers, and eval: true has limitations (no real file URL, import() complications).

    Suggestion for node:vfs

    Since node:sea assets are process-wide, the SEA VFS initialization (lib/internal/vfs/sea.js) could be extended to automatically run in worker threads too. Something like:

    1. During SEA preparation, store the VFS config (mount point, provider type) in the SEA blob metadata
    2. In prepareWorkerThreadExecution (or equivalent), check if running as SEA with VFS enabled
    3. If so, call initSeaVfs() with the stored config to set up the same VFS mount in the worker

    This would make SEA + VFS + worker threads work transparently without any userland patching.

    Alternatively, a more general mechanism could allow VFS mounts to be "inherited" by workers, perhaps via a flag on the Worker constructor:

    new Worker('./worker.js', { inheritVFS: true })

    Context

    • The test test-vfs-chdir-worker.js in this PR explicitly asserts that VFS is NOT shared with workers — so this is by-design, not a bug
    • But for SEA use cases, this makes worker threads essentially broken unless the consumer implements the workaround above
    • Libraries like pino, workerpool, jest-worker, and many others use worker threads
    • The traditional pkg approach (C++ libuv-level patching) didn't have this issue since the patches were process-wide

    Related: yao-pkg/pkg#229, platformatic/vfs#9, platformatic/vfs#10

  10. RafaelGSS commented on Apr 15, 2026

    @RafaelGSS
    Member

    About the number one (permission model)

    1.1 VFS interception bypasses the Node.js permission model — CRITICAL

    Initially, I thought vfs operates only on in-memmory temporary folder/files, but while seeing Matteo's presentation of VFS feature on Collaborator Summit, I saw you could .mount() the vfs into the file system and make real calls to uv_fs, bypassing the permission model guarantees (on the fs module) entirely. The checks with permission.has() might not be sufficient if they are not placed in the same manner we do node_file.cc. Ideally, we could think about a way the vfs does reuse the fs API, but pass a flag if that's a vfs or not, so we won't need to change or reallocate permission model checks.

    UPDATE: Alright, so after talking with Matteo to understand more about the implementation, there's no uv_fs call with the vfs implementation, the only concern is that it will mock node:fs operations to the vfs, so operations that were supposed to fail (write access to unnauthorized paths), might succeedd (although no real write access would happen).

  11. github-actions commented on Jul 20, 2026

    @github-actions
    Contributor

    This issue has been marked as stale due to 90 days of inactivity.
    It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

  12. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.vfsIssues and PRs related to the virtual filesystem subsystem.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      Sponsor
      SponsoredKunjungi sekarang
      Promo