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();