Skip to content

Commit a701540

Browse files
authored
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 `<mount>.js`, `<mount>.json` and `<mount>.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\<anything>` opens the NUL device: libuv reports it as a character device and a read returns nothing. The loader therefore picked `\\.\nul\vfs\<id>.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: #65748 Signed-off-by: Philipp Dunkel <pip@pipobscure.com> PR-URL: #65814 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
1 parent fe4a42b commit a701540

2 files changed

Lines changed: 133 additions & 36 deletions

File tree

lib/internal/vfs/setup.js

Lines changed: 74 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const { assertEncoding, setVfsHandlers } = require('internal/fs/utils');
3535
const permission = require('internal/process/permission');
3636
const { getOptionValue } = require('internal/options');
3737
const nativeModulesBinding = internalBinding('modules');
38+
const { UV_ENOENT } = internalBinding('uv');
3839
let debug = require('internal/util/debuglog').debuglog('vfs', (fn) => {
3940
debug = fn;
4041
});
@@ -115,29 +116,50 @@ function deregisterVFS(vfs) {
115116
}
116117

117118
/**
118-
* Resolves a path string to the active VFS that owns it, or null.
119-
* Ownership is decidable from the path alone: all mount points live
120-
* under the reserved `${os.devNull}/vfs/<id>` namespace, so a single
121-
* prefix comparison rejects every real-file-system path and a map
122-
* lookup finds the owning layer. The normalized path is returned
119+
* Resolves a path string to the reserved VFS root, or null for a path
120+
* outside it. Ownership is decidable from the path alone: all mount
121+
* points live under the reserved `${os.devNull}/vfs/<id>` namespace, so
122+
* a single prefix comparison rejects every real-file-system path and a
123+
* map lookup finds the owning layer. The normalized path is returned
123124
* alongside the layer so downstream helpers can skip renormalization.
125+
*
126+
* A path under the root that no active layer owns comes back with
127+
* `vfs: null` rather than as `null`, because the two cases must not be
128+
* treated alike by the module loader. The loader manufactures such
129+
* paths itself: resolving a mount point as a directory first probes the
130+
* sibling names `<mount>.js`, `<mount>.json`, ..., and a package.json
131+
* walk-up passes the parents of the mount point. They cannot name
132+
* anything real, but on Windows the root sits under `\\.\nul`, and
133+
* `\\.\nul\<anything>` opens the NUL device, which stats as a character
134+
* device and reads as empty. Handed to the native loader, such a probe
135+
* "finds" a file and the walk-up above it rejects the empty device as an
136+
* invalid package.json, so the loader must answer for the whole root.
124137
* @param {string} inputPath
125-
* @returns {{ vfs: object, normalized: string }|null}
138+
* @returns {{ vfs: object|null, normalized: string }|null}
126139
*/
127-
function findVFS(inputPath) {
140+
function findVFSOrRoot(inputPath) {
128141
const normalized = normalizeMountedPath(inputPath);
129142
if (!StringPrototypeStartsWith(normalized, normalizedVfsRootPrefix)) {
130143
return null;
131144
}
132145
const layerId = getLayerIdFromPath(normalized);
133-
if (layerId === -1) return null;
134-
const vfs = activeVFSLayers.get(layerId);
146+
const vfs = layerId === -1 ? undefined : activeVFSLayers.get(layerId);
135147
if (vfs === undefined || !vfs.shouldHandleNormalized(normalized)) {
136-
return null;
148+
return { vfs: null, normalized };
137149
}
138150
return { vfs, normalized };
139151
}
140152

153+
/**
154+
* Resolves a path string to the active VFS that owns it, or null.
155+
* @param {string} inputPath
156+
* @returns {{ vfs: object, normalized: string }|null}
157+
*/
158+
function findVFS(inputPath) {
159+
const r = findVFSOrRoot(inputPath);
160+
return r === null || r.vfs === null ? null : r;
161+
}
162+
141163
/**
142164
* Drop the cache entries under `vfs`'s mount point from the
143165
* JS-reachable loader caches. Real-fs entries and other-VFS entries
@@ -210,16 +232,16 @@ function findVFSForStat(filename) {
210232
}
211233

212234
/**
213-
* Finds the VFS owning `filename` and reads it.
235+
* Reads `filename` from the VFS that owns it, reporting a missing file
236+
* or a directory the way the native loader's read does.
237+
* @param {object} vfs The VFS owning filename
214238
* @param {string} filename The absolute path to read
215239
* @param {string|object} options Read options
216-
* @returns {{ vfs: object, content: Buffer|string }|null}
240+
* @returns {Buffer|string}
217241
*/
218-
function findVFSForRead(filename, options) {
219-
const r = findVFS(filename);
220-
if (r === null) return null;
242+
function readVFS(vfs, filename, options) {
221243
try {
222-
return { vfs: r.vfs, content: r.vfs.readFileSync(filename, options) };
244+
return vfs.readFileSync(filename, options);
223245
} catch (e) {
224246
const code = e?.code;
225247
if (code === 'ENOENT' || code === 'EISDIR') {
@@ -794,30 +816,40 @@ function installModuleLoaderOverrides() {
794816
// wrapLoaderMethod then falls through to the native binding.
795817
setLoaderOverrides({
796818
internalModuleStat(filename) {
797-
const result = findVFSForStat(filename);
798-
return result !== null ? result.result : undefined;
819+
const r = findVFSOrRoot(filename);
820+
if (r === null) return undefined;
821+
return r.vfs === null ? UV_ENOENT : vfsStat(r.vfs, filename);
799822
},
800823
readFileSync(filename, options) {
801824
const pathStr = typeof filename === 'string' ? filename :
802825
(filename instanceof URL ? fileURLToPath(filename) : String(filename));
803-
const result = findVFSForRead(pathStr, options);
804-
return result !== null ? result.content : undefined;
826+
const r = findVFSOrRoot(pathStr);
827+
if (r === null) return undefined;
828+
if (r.vfs === null) throw createENOENT('open', pathStr);
829+
return readVFS(r.vfs, pathStr, options);
805830
},
806831
realpathSync(filename) {
807-
return findVFSWith(filename, 'realpath', (vfs, n) => vfs.realpathSync(n));
832+
const r = findVFSOrRoot(filename);
833+
if (r === null) return undefined;
834+
if (r.vfs === null || !r.vfs.existsSync(filename)) {
835+
throw createENOENT('realpath', filename);
836+
}
837+
return r.vfs.realpathSync(filename);
808838
},
809839
getResolutionRoot(pathStr) {
810-
const r = findVFS(pathStr);
840+
const r = findVFSOrRoot(pathStr);
811841
if (r === null) return undefined;
812-
const mountPoint = r.vfs.mountPoint;
813842
// The boundary is compared as a plain string prefix by the
814-
// callers, so only report it when the input carries the mount
815-
// point verbatim.
816-
return StringPrototypeStartsWith(pathStr, mountPoint) ?
817-
mountPoint : undefined;
843+
// callers, so only report it when the input carries it verbatim.
844+
// An unowned path stops at the reserved root itself, so no
845+
// node_modules lookup walks out into the real file system.
846+
const boundary = r.vfs === null ?
847+
getNormalizedVfsRoot() : r.vfs.mountPoint;
848+
return StringPrototypeStartsWith(pathStr, boundary) ?
849+
boundary : undefined;
818850
},
819851
legacyMainResolve(pkgPath, main, base) {
820-
if (findVFS(pkgPath) === null) return undefined;
852+
if (findVFSOrRoot(pkgPath) === null) return undefined;
821853

822854
for (let i = 0; i < legacyMainResolveExtensions.length; i++) {
823855
const byMain = i <= kResolvedByMainIndexNode;
@@ -835,14 +867,14 @@ function installModuleLoaderOverrides() {
835867
throw new ERR_MODULE_NOT_FOUND(initial, base, undefined);
836868
},
837869
getFormatOfExtensionlessFile(filePath) {
838-
let result;
870+
const r = findVFSOrRoot(filePath);
871+
if (r === null) return undefined;
872+
let content;
839873
try {
840-
result = findVFSForRead(filePath, null);
874+
content = r.vfs === null ? null : readVFS(r.vfs, filePath, null);
841875
} catch {
842876
return internalConstants.EXTENSIONLESS_FORMAT_JAVASCRIPT;
843877
}
844-
if (result === null) return undefined;
845-
const content = result.content;
846878
// Wasm magic bytes: 0x00 0x61 0x73 0x6d
847879
if (content && content.length >= 4 &&
848880
content[0] === 0x00 && content[1] === 0x61 &&
@@ -852,8 +884,9 @@ function installModuleLoaderOverrides() {
852884
return internalConstants.EXTENSIONLESS_FORMAT_JAVASCRIPT;
853885
},
854886
readPackageJSON(jsonPath, isESM, base, specifier) {
855-
const r = findVFS(jsonPath);
887+
const r = findVFSOrRoot(jsonPath);
856888
if (r === null) return undefined;
889+
if (r.vfs === null) return kLoaderOverrideNoResult;
857890
const { vfs } = r;
858891
if (vfsStat(vfs, jsonPath) !== 0) return kLoaderOverrideNoResult;
859892
let content;
@@ -868,8 +901,9 @@ function installModuleLoaderOverrides() {
868901
content, jsonPath, isESM, base, specifier);
869902
},
870903
getNearestParentPackageJSON(checkPath) {
871-
const r = findVFS(checkPath);
904+
const r = findVFSOrRoot(checkPath);
872905
if (r === null) return undefined;
906+
if (r.vfs === null) return kLoaderOverrideNoResult;
873907
const found = findVFSPackageJSON(r.vfs, checkPath, r.normalized);
874908
return found.tuple ?? kLoaderOverrideNoResult;
875909
},
@@ -884,8 +918,11 @@ function installModuleLoaderOverrides() {
884918
} else {
885919
filePath = resolved;
886920
}
887-
const r = findVFS(filePath);
921+
const r = findVFSOrRoot(filePath);
888922
if (r === null) return undefined;
923+
// The "not found" marker is the package.json beside the queried
924+
// path, which is what the native binding reports for it.
925+
if (r.vfs === null) return join(dirname(filePath), 'package.json');
889926
const found = findVFSPackageJSON(r.vfs, filePath, r.normalized);
890927
if (found.tuple !== undefined) return found.tuple;
891928
return found.sentinel;
@@ -901,8 +938,9 @@ function installModuleLoaderOverrides() {
901938
} else {
902939
filePath = url;
903940
}
904-
const r = findVFS(filePath);
941+
const r = findVFSOrRoot(filePath);
905942
if (r === null) return undefined;
943+
if (r.vfs === null) return kLoaderOverrideNoResult;
906944
const found = findVFSPackageJSON(r.vfs, filePath, r.normalized);
907945
if (found.tuple !== undefined) {
908946
// Tuple shape: [name, main, type, imports, exports, filePath].
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Flags: --experimental-vfs --expose-internals
2+
'use strict';
3+
4+
// The module loader manufactures paths under the reserved VFS root that no
5+
// layer owns: resolving a mount point as a directory first probes the sibling
6+
// names `<mount>.js`, `<mount>.json`, ..., and a package.json walk-up passes
7+
// the parents of the mount point. Such paths cannot name anything real, but
8+
// they must still be answered by the VFS instead of being handed to the native
9+
// loader: on Windows the reserved root sits under `\\.\nul`, and
10+
// `\\.\nul\<anything>` opens the NUL device, which stats as a character device
11+
// and reads as empty. The native loader would then "find" a file at
12+
// `<mount>.js` and reject the empty package.json above it as invalid JSON.
13+
14+
require('../common');
15+
const assert = require('assert');
16+
const path = require('path');
17+
const { pathToFileURL } = require('url');
18+
const vfs = require('node:vfs');
19+
const { loaderMethods } = require('internal/modules/helpers');
20+
const { getNormalizedVfsRoot } = require('internal/vfs/router');
21+
22+
const layer = vfs.create();
23+
layer.writeFileSync('/index.js', 'module.exports = "ran";');
24+
const mountPoint = layer.mount();
25+
26+
const root = getNormalizedVfsRoot();
27+
const unowned = [
28+
`${mountPoint}.js`,
29+
`${mountPoint}.json`,
30+
`${mountPoint}.node`,
31+
path.join(root, 'package.json'),
32+
path.join(root, 'nope', 'index.js'),
33+
];
34+
35+
for (const p of unowned) {
36+
assert.ok(loaderMethods.internalModuleStat(p) < 0, p);
37+
assert.throws(() => loaderMethods.readFileSync(p), { code: 'ENOENT' }, p);
38+
assert.throws(() => loaderMethods.realpathSync(p), { code: 'ENOENT' }, p);
39+
assert.strictEqual(loaderMethods.getNearestParentPackageJSON(p), undefined, p);
40+
assert.strictEqual(loaderMethods.readPackageJSON(p, false), undefined, p);
41+
assert.strictEqual(loaderMethods.getPackageType(pathToFileURL(p).href), undefined, p);
42+
// The "not found" marker is the last candidate examined, like the native
43+
// binding returns.
44+
assert.strictEqual(
45+
loaderMethods.getPackageScopeConfig(pathToFileURL(p).href),
46+
path.join(path.dirname(p), 'package.json'), p);
47+
// Upward walks (node_modules lookups) stop at the reserved root rather than
48+
// continuing into the real file system.
49+
assert.strictEqual(loaderMethods.getResolutionRoot(p), root, p);
50+
}
51+
52+
// Paths outside the reserved root are still left to the native loader.
53+
assert.strictEqual(loaderMethods.getResolutionRoot(__filename), undefined);
54+
55+
// The mount point itself resolves as a directory to its index through the
56+
// layer, which is the sequence that produced the sibling probes above.
57+
assert.strictEqual(require(mountPoint), 'ran');
58+
59+
layer.unmount();

0 commit comments

Comments
 (0)