From fc612c844b06097b15eafb213e8347fe531c91ab Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Sat, 5 Sep 2026 16:33:45 +0200 Subject: [PATCH] vfs: answer for unowned paths under reserved root The module loader manufactures paths under the reserved VFS root that no layer owns: resolving a mount point as a directory first probes the sibling names `.js`, `.json` and `.node`, and a package.json walk-up passes the parents of the mount point. The lookup declined those because their layer segment is not a plain id, so they fell through to the native loader and the real file system. On POSIX that is harmless (ENOTDIR under /dev/null), but on Windows the root sits under `\\.\nul`, and `\\.\nul\` opens the NUL device: libuv reports it as a character device and a read returns nothing. The loader therefore picked `\\.\nul\vfs\.js` as an existing file, and the native walk-up above it then read the device as an empty package.json and failed with ERR_INVALID_PACKAGE_CONFIG for `\\.\nul\package.json`. Any require() of a mount point hits this on Windows. Distinguish "under the root but unowned" from "outside the root" in the lookup and have every loader override report the former as not found: stat gives ENOENT, reads and realpath throw ENOENT, the package.json lookups return their "no package.json" results, and upward walks stop at the reserved root. Paths outside the root still go to the native loader as before. Refs: https://github.com/nodejs/node/pull/65748 Signed-off-by: Philipp Dunkel --- lib/internal/vfs/setup.js | 110 ++++++++++++------ .../test-vfs-reserved-root-unowned.js | 59 ++++++++++ 2 files changed, 133 insertions(+), 36 deletions(-) create mode 100644 test/parallel/test-vfs-reserved-root-unowned.js diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index a45cd47a9bf4..3e6f246d794a 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -35,6 +35,7 @@ const { assertEncoding, setVfsHandlers } = require('internal/fs/utils'); const permission = require('internal/process/permission'); const { getOptionValue } = require('internal/options'); const nativeModulesBinding = internalBinding('modules'); +const { UV_ENOENT } = internalBinding('uv'); let debug = require('internal/util/debuglog').debuglog('vfs', (fn) => { debug = fn; }); @@ -115,29 +116,50 @@ function deregisterVFS(vfs) { } /** - * Resolves a path string to the active VFS that owns it, or null. - * Ownership is decidable from the path alone: all mount points live - * under the reserved `${os.devNull}/vfs/` namespace, so a single - * prefix comparison rejects every real-file-system path and a map - * lookup finds the owning layer. The normalized path is returned + * Resolves a path string to the reserved VFS root, or null for a path + * outside it. Ownership is decidable from the path alone: all mount + * points live under the reserved `${os.devNull}/vfs/` namespace, so + * a single prefix comparison rejects every real-file-system path and a + * map lookup finds the owning layer. The normalized path is returned * alongside the layer so downstream helpers can skip renormalization. + * + * A path under the root that no active layer owns comes back with + * `vfs: null` rather than as `null`, because the two cases must not be + * treated alike by the module loader. The loader manufactures such + * paths itself: resolving a mount point as a directory first probes the + * sibling names `.js`, `.json`, ..., and a package.json + * walk-up passes the parents of the mount point. They cannot name + * anything real, but on Windows the root sits under `\\.\nul`, and + * `\\.\nul\` opens the NUL device, which stats as a character + * device and reads as empty. Handed to the native loader, such a probe + * "finds" a file and the walk-up above it rejects the empty device as an + * invalid package.json, so the loader must answer for the whole root. * @param {string} inputPath - * @returns {{ vfs: object, normalized: string }|null} + * @returns {{ vfs: object|null, normalized: string }|null} */ -function findVFS(inputPath) { +function findVFSOrRoot(inputPath) { const normalized = normalizeMountedPath(inputPath); if (!StringPrototypeStartsWith(normalized, normalizedVfsRootPrefix)) { return null; } const layerId = getLayerIdFromPath(normalized); - if (layerId === -1) return null; - const vfs = activeVFSLayers.get(layerId); + const vfs = layerId === -1 ? undefined : activeVFSLayers.get(layerId); if (vfs === undefined || !vfs.shouldHandleNormalized(normalized)) { - return null; + return { vfs: null, normalized }; } return { vfs, normalized }; } +/** + * Resolves a path string to the active VFS that owns it, or null. + * @param {string} inputPath + * @returns {{ vfs: object, normalized: string }|null} + */ +function findVFS(inputPath) { + const r = findVFSOrRoot(inputPath); + return r === null || r.vfs === null ? null : r; +} + /** * Drop the cache entries under `vfs`'s mount point from the * JS-reachable loader caches. Real-fs entries and other-VFS entries @@ -210,16 +232,16 @@ function findVFSForStat(filename) { } /** - * Finds the VFS owning `filename` and reads it. + * Reads `filename` from the VFS that owns it, reporting a missing file + * or a directory the way the native loader's read does. + * @param {object} vfs The VFS owning filename * @param {string} filename The absolute path to read * @param {string|object} options Read options - * @returns {{ vfs: object, content: Buffer|string }|null} + * @returns {Buffer|string} */ -function findVFSForRead(filename, options) { - const r = findVFS(filename); - if (r === null) return null; +function readVFS(vfs, filename, options) { try { - return { vfs: r.vfs, content: r.vfs.readFileSync(filename, options) }; + return vfs.readFileSync(filename, options); } catch (e) { const code = e?.code; if (code === 'ENOENT' || code === 'EISDIR') { @@ -794,30 +816,40 @@ function installModuleLoaderOverrides() { // wrapLoaderMethod then falls through to the native binding. setLoaderOverrides({ internalModuleStat(filename) { - const result = findVFSForStat(filename); - return result !== null ? result.result : undefined; + const r = findVFSOrRoot(filename); + if (r === null) return undefined; + return r.vfs === null ? UV_ENOENT : vfsStat(r.vfs, filename); }, readFileSync(filename, options) { const pathStr = typeof filename === 'string' ? filename : (filename instanceof URL ? fileURLToPath(filename) : String(filename)); - const result = findVFSForRead(pathStr, options); - return result !== null ? result.content : undefined; + const r = findVFSOrRoot(pathStr); + if (r === null) return undefined; + if (r.vfs === null) throw createENOENT('open', pathStr); + return readVFS(r.vfs, pathStr, options); }, realpathSync(filename) { - return findVFSWith(filename, 'realpath', (vfs, n) => vfs.realpathSync(n)); + const r = findVFSOrRoot(filename); + if (r === null) return undefined; + if (r.vfs === null || !r.vfs.existsSync(filename)) { + throw createENOENT('realpath', filename); + } + return r.vfs.realpathSync(filename); }, getResolutionRoot(pathStr) { - const r = findVFS(pathStr); + const r = findVFSOrRoot(pathStr); if (r === null) return undefined; - const mountPoint = r.vfs.mountPoint; // The boundary is compared as a plain string prefix by the - // callers, so only report it when the input carries the mount - // point verbatim. - return StringPrototypeStartsWith(pathStr, mountPoint) ? - mountPoint : undefined; + // callers, so only report it when the input carries it verbatim. + // An unowned path stops at the reserved root itself, so no + // node_modules lookup walks out into the real file system. + const boundary = r.vfs === null ? + getNormalizedVfsRoot() : r.vfs.mountPoint; + return StringPrototypeStartsWith(pathStr, boundary) ? + boundary : undefined; }, legacyMainResolve(pkgPath, main, base) { - if (findVFS(pkgPath) === null) return undefined; + if (findVFSOrRoot(pkgPath) === null) return undefined; for (let i = 0; i < legacyMainResolveExtensions.length; i++) { const byMain = i <= kResolvedByMainIndexNode; @@ -835,14 +867,14 @@ function installModuleLoaderOverrides() { throw new ERR_MODULE_NOT_FOUND(initial, base, undefined); }, getFormatOfExtensionlessFile(filePath) { - let result; + const r = findVFSOrRoot(filePath); + if (r === null) return undefined; + let content; try { - result = findVFSForRead(filePath, null); + content = r.vfs === null ? null : readVFS(r.vfs, filePath, null); } catch { return internalConstants.EXTENSIONLESS_FORMAT_JAVASCRIPT; } - if (result === null) return undefined; - const content = result.content; // Wasm magic bytes: 0x00 0x61 0x73 0x6d if (content && content.length >= 4 && content[0] === 0x00 && content[1] === 0x61 && @@ -852,8 +884,9 @@ function installModuleLoaderOverrides() { return internalConstants.EXTENSIONLESS_FORMAT_JAVASCRIPT; }, readPackageJSON(jsonPath, isESM, base, specifier) { - const r = findVFS(jsonPath); + const r = findVFSOrRoot(jsonPath); if (r === null) return undefined; + if (r.vfs === null) return kLoaderOverrideNoResult; const { vfs } = r; if (vfsStat(vfs, jsonPath) !== 0) return kLoaderOverrideNoResult; let content; @@ -868,8 +901,9 @@ function installModuleLoaderOverrides() { content, jsonPath, isESM, base, specifier); }, getNearestParentPackageJSON(checkPath) { - const r = findVFS(checkPath); + const r = findVFSOrRoot(checkPath); if (r === null) return undefined; + if (r.vfs === null) return kLoaderOverrideNoResult; const found = findVFSPackageJSON(r.vfs, checkPath, r.normalized); return found.tuple ?? kLoaderOverrideNoResult; }, @@ -884,8 +918,11 @@ function installModuleLoaderOverrides() { } else { filePath = resolved; } - const r = findVFS(filePath); + const r = findVFSOrRoot(filePath); if (r === null) return undefined; + // The "not found" marker is the package.json beside the queried + // path, which is what the native binding reports for it. + if (r.vfs === null) return join(dirname(filePath), 'package.json'); const found = findVFSPackageJSON(r.vfs, filePath, r.normalized); if (found.tuple !== undefined) return found.tuple; return found.sentinel; @@ -901,8 +938,9 @@ function installModuleLoaderOverrides() { } else { filePath = url; } - const r = findVFS(filePath); + const r = findVFSOrRoot(filePath); if (r === null) return undefined; + if (r.vfs === null) return kLoaderOverrideNoResult; const found = findVFSPackageJSON(r.vfs, filePath, r.normalized); if (found.tuple !== undefined) { // Tuple shape: [name, main, type, imports, exports, filePath]. diff --git a/test/parallel/test-vfs-reserved-root-unowned.js b/test/parallel/test-vfs-reserved-root-unowned.js new file mode 100644 index 000000000000..e6a5b319f610 --- /dev/null +++ b/test/parallel/test-vfs-reserved-root-unowned.js @@ -0,0 +1,59 @@ +// Flags: --experimental-vfs --expose-internals +'use strict'; + +// The module loader manufactures paths under the reserved VFS root that no +// layer owns: resolving a mount point as a directory first probes the sibling +// names `.js`, `.json`, ..., and a package.json walk-up passes +// the parents of the mount point. Such paths cannot name anything real, but +// they must still be answered by the VFS instead of being handed to the native +// loader: on Windows the reserved root sits under `\\.\nul`, and +// `\\.\nul\` opens the NUL device, which stats as a character device +// and reads as empty. The native loader would then "find" a file at +// `.js` and reject the empty package.json above it as invalid JSON. + +require('../common'); +const assert = require('assert'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const vfs = require('node:vfs'); +const { loaderMethods } = require('internal/modules/helpers'); +const { getNormalizedVfsRoot } = require('internal/vfs/router'); + +const layer = vfs.create(); +layer.writeFileSync('/index.js', 'module.exports = "ran";'); +const mountPoint = layer.mount(); + +const root = getNormalizedVfsRoot(); +const unowned = [ + `${mountPoint}.js`, + `${mountPoint}.json`, + `${mountPoint}.node`, + path.join(root, 'package.json'), + path.join(root, 'nope', 'index.js'), +]; + +for (const p of unowned) { + assert.ok(loaderMethods.internalModuleStat(p) < 0, p); + assert.throws(() => loaderMethods.readFileSync(p), { code: 'ENOENT' }, p); + assert.throws(() => loaderMethods.realpathSync(p), { code: 'ENOENT' }, p); + assert.strictEqual(loaderMethods.getNearestParentPackageJSON(p), undefined, p); + assert.strictEqual(loaderMethods.readPackageJSON(p, false), undefined, p); + assert.strictEqual(loaderMethods.getPackageType(pathToFileURL(p).href), undefined, p); + // The "not found" marker is the last candidate examined, like the native + // binding returns. + assert.strictEqual( + loaderMethods.getPackageScopeConfig(pathToFileURL(p).href), + path.join(path.dirname(p), 'package.json'), p); + // Upward walks (node_modules lookups) stop at the reserved root rather than + // continuing into the real file system. + assert.strictEqual(loaderMethods.getResolutionRoot(p), root, p); +} + +// Paths outside the reserved root are still left to the native loader. +assert.strictEqual(loaderMethods.getResolutionRoot(__filename), undefined); + +// The mount point itself resolves as a directory to its index through the +// layer, which is the sequence that produced the sibling probes above. +assert.strictEqual(require(mountPoint), 'ran'); + +layer.unmount();