Skip to content

Commit 26f15b0

Browse files
fix(path): classify DOS device paths as Windows-absolute (#551)
1 parent a04bc4c commit 26f15b0

7 files changed

Lines changed: 292 additions & 5 deletions

File tree

.changeset/fix-dos-device-paths.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"enhanced-resolve": patch
3+
---
4+
5+
fix: properly handle DOS device paths (`\\?\…` and `\\.\…`) on Windows
6+
7+
Requests and context paths using the Win32 file namespace (`\\?\C:\…`,
8+
`\\?\UNC\server\share\…`) or device namespace (`\\.\C:\…`) were not
9+
handled correctly:
10+
11+
- `getType()` classified them as `Normal`, so `normalize`, `dirname`,
12+
and `join` ran them through posix helpers and failed to collapse `..`
13+
segments or compute parents correctly.
14+
- `parseIdentifier()` split on the literal `?` inside `\\?\`, turning a
15+
valid absolute request into a bogus module lookup under
16+
`node_modules`.
17+
- `cdUp()` returned `\` from `\` (via `slice(0, i || 1)`), so
18+
`loadDescriptionFile` walked forever once it reached the UNC/device
19+
root.
20+
21+
These paths are now recognized as Windows-absolute, parsed without
22+
misinterpreting the prefix `?`, and the description-file walk
23+
terminates at a bare `\` root. Plain UNC (`\\server\share\…`) remains
24+
out of scope.

lib/DescriptionFileUtils.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,19 @@ const CHAR_BACKSLASH = 92;
4545
* separately and then picked the larger. For any non-trivial directory
4646
* string on POSIX, `lastIndexOf("\\")` scans the full string just to return
4747
* -1. A single reverse char-code scan does the same work in one pass.
48+
*
49+
* Any single-character directory is treated as a root — `directory.length
50+
* <= 1` collapses the `"/"`, `"\\"` and `""` branches into one compare.
51+
* Without the `"\\"` case, `cdUp("\\")` (reached from a UNC root or a DOS
52+
* device path like `\\?\…`) would return itself via `slice(0, i || 1)`
53+
* and trap `loadDescriptionFile` in an infinite loop. Once single-char
54+
* roots are filtered up front, the reverse scan always produces a
55+
* strictly shorter string.
4856
* @param {string} directory directory
4957
* @returns {string | null} parent directory or null
5058
*/
5159
function cdUp(directory) {
52-
if (directory === "/") return null;
60+
if (directory.length <= 1) return null;
5361
for (let i = directory.length - 1; i >= 0; i--) {
5462
const code = directory.charCodeAt(i);
5563
if (code === CHAR_SLASH || code === CHAR_BACKSLASH) {

lib/util/identifier.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,26 @@ const PATH_QUERY_FRAGMENT_REGEXP =
1414
const ZERO_ESCAPE_REGEXP = /\0(.)/g;
1515
const FILE_REG_EXP = /file:/i;
1616

17+
/**
18+
* Index past a DOS device path prefix (`\\?\…` or `\\.\…`), or 0. Kept
19+
* out of `parseIdentifier` on purpose: inlining it back bloats the caller
20+
* beyond the size where V8's interpreter and JIT both handle it well
21+
* (the cause of the description-files-multi CodSpeed regression).
22+
* @param {string} identifier identifier known to start with `\`
23+
* @returns {number} 4 if identifier starts with a DOS device prefix, else 0
24+
*/
25+
function dosPrefixEnd(identifier) {
26+
if (
27+
identifier.length >= 4 &&
28+
identifier.charCodeAt(1) === 92 &&
29+
identifier.charCodeAt(3) === 92
30+
) {
31+
const c2 = identifier.charCodeAt(2);
32+
if (c2 === 63 || c2 === 46) return 4;
33+
}
34+
return 0;
35+
}
36+
1737
/**
1838
* @param {string} identifier identifier
1939
* @returns {[string, string, string] | null} parsed identifier
@@ -42,10 +62,16 @@ function parseIdentifier(identifier) {
4262
];
4363
}
4464

45-
// Fast path for inputs that don't use \0 escaping.
46-
const queryStart = identifier.indexOf("?");
47-
// Start at index 1 to ignore a possible leading hash.
48-
const fragmentStart = identifier.indexOf("#", 1);
65+
// Fast path for inputs that don't use \0 escaping. DOS device paths
66+
// (`\\?\…`, `\\.\…`) embed a literal `?` / `.` that must not be read
67+
// as a query separator; skip past the prefix when the input actually
68+
// starts with `\`. Gate is a single char-code compare so this function
69+
// stays inside V8's inline budget for its hot callers (resolver parse).
70+
const scanStart =
71+
identifier.charCodeAt(0) === 92 ? dosPrefixEnd(identifier) : 0;
72+
const queryStart = identifier.indexOf("?", scanStart);
73+
// Start at index 1 (or past a DOS prefix) to ignore a possible leading hash.
74+
const fragmentStart = identifier.indexOf("#", scanStart || 1);
4975

5076
if (fragmentStart < 0) {
5177
if (queryStart < 0) {

lib/util/path.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const CHAR_LOWER_A = "a".charCodeAt(0);
1616
const CHAR_LOWER_Z = "z".charCodeAt(0);
1717
const CHAR_DOT = ".".charCodeAt(0);
1818
const CHAR_COLON = ":".charCodeAt(0);
19+
const CHAR_QUESTION = "?".charCodeAt(0);
1920

2021
const posixNormalize = path.posix.normalize;
2122
const winNormalize = path.win32.normalize;
@@ -38,6 +39,20 @@ const deprecatedInvalidSegmentRegEx =
3839
const invalidSegmentRegEx =
3940
/(^|\\|\/)((\.|%2e)(\.|%2e)?|(n|%6e|%4e)(o|%6f|%4f)(d|%64|%44)(e|%65|%45)(_|%5f)(m|%6d|%4d)(o|%6f|%4f)(d|%64|%44)(u|%75|%55)(l|%6c|%4c)(e|%65|%45)(s|%73|%53))?(\\|\/|$)/i;
4041

42+
/**
43+
* @param {string} maybePath a path known to start with `\\`
44+
* @returns {PathType} AbsoluteWin for `\\?\…` / `\\.\…`, otherwise Normal
45+
*/
46+
const getDosDeviceType = (maybePath) => {
47+
if (maybePath.length >= 4 && maybePath.charCodeAt(3) === CHAR_BACKSLASH) {
48+
const c2 = maybePath.charCodeAt(2);
49+
if (c2 === CHAR_QUESTION || c2 === CHAR_DOT) {
50+
return PathType.AbsoluteWin;
51+
}
52+
}
53+
return PathType.Normal;
54+
};
55+
4156
/**
4257
* @param {string} maybePath a path
4358
* @returns {PathType} type of path
@@ -117,6 +132,13 @@ const getType = (maybePath) => {
117132
return PathType.AbsoluteWin;
118133
}
119134
}
135+
// DOS device paths (`\\?\…`, `\\.\…`) are handled in a cold helper so
136+
// this function stays small — inlining the full check here regressed
137+
// `description-files-multi` under `--no-opt` interpretation. Here we
138+
// only pay the two-byte gate for non-DOS inputs.
139+
if (c0 === CHAR_BACKSLASH && c1 === CHAR_BACKSLASH) {
140+
return getDosDeviceType(maybePath);
141+
}
120142
return PathType.Normal;
121143
};
122144

test/dos-device-paths.test.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"use strict";
2+
3+
// eslint's jest plugin static-analyzes `describe` calls and doesn't recognize
4+
// `describeWin` below as one — disable `require-top-level-describe` for the
5+
// whole file rather than scatter per-test ignore comments.
6+
/* eslint-disable jest/require-top-level-describe, jsdoc/reject-any-type */
7+
8+
const fs = require("fs");
9+
const path = require("path");
10+
const { ResolverFactory } = require("../");
11+
12+
// DOS device paths (`\\?\…`, `\\.\…`) are Windows-only constructs. The real
13+
// resolver tests below hit the actual filesystem through those prefixes, so
14+
// they only make sense on a Windows host. `describe.skip` elsewhere keeps CI
15+
// on other platforms green while still validating the pure logic via
16+
// `path.test.js` and `identifier.test.js`.
17+
const describeWin = process.platform === "win32" ? describe : describe.skip;
18+
19+
describeWin("DOS device path resolution (Windows)", () => {
20+
// `path.resolve` gives us the real Windows-absolute form of the fixtures
21+
// directory (e.g. `C:\…\test\fixtures`), which we then re-address through
22+
// the Win32 file (`\\?\`) and device (`\\.\`) namespaces.
23+
const fixtures = path.resolve(__dirname, "fixtures");
24+
const dosFixtures = `\\\\?\\${fixtures}`;
25+
const dotFixtures = `\\\\.\\${fixtures}`;
26+
27+
// `symlinks: false` keeps the result verbatim — the realpath plugin would
28+
// otherwise strip the DOS prefix on Windows, masking what we want to test.
29+
// Default (async) filesystem calls are used — sync fs is deliberately
30+
// avoided so this exercises the same code paths as production resolves.
31+
const resolver = ResolverFactory.createResolver({
32+
fileSystem: fs,
33+
extensions: [".js"],
34+
symlinks: false,
35+
});
36+
37+
test("resolves a relative request against a \\\\?\\ context", async () => {
38+
await expect(resolver.resolvePromise({}, dosFixtures, "./a")).resolves.toBe(
39+
path.join(dosFixtures, "a.js"),
40+
);
41+
});
42+
43+
test("resolves a relative request to a subdirectory's index.js", async () => {
44+
await expect(
45+
resolver.resolvePromise({}, dosFixtures, "./foo"),
46+
).resolves.toBe(path.join(dosFixtures, "foo", "index.js"));
47+
});
48+
49+
test("resolves '.' to index.js in a \\\\?\\ context", async () => {
50+
await expect(
51+
resolver.resolvePromise({}, path.join(dosFixtures, "foo"), "."),
52+
).resolves.toBe(path.join(dosFixtures, "foo", "index.js"));
53+
});
54+
55+
test("resolves an absolute \\\\?\\ request regardless of context", async () => {
56+
const request = path.join(dosFixtures, "a");
57+
await expect(resolver.resolvePromise({}, fixtures, request)).resolves.toBe(
58+
path.join(dosFixtures, "a.js"),
59+
);
60+
});
61+
62+
test("resolves through the \\\\.\\ device namespace", async () => {
63+
// The `\\.\` walk used to infinite-loop in `cdUp` once it reached
64+
// the bare `\` root — this test proves it terminates.
65+
await expect(resolver.resolvePromise({}, dotFixtures, "./a")).resolves.toBe(
66+
path.join(dotFixtures, "a.js"),
67+
);
68+
});
69+
70+
test("preserves a query string on a \\\\?\\ request", async () => {
71+
// The literal `?` inside `\\?\` must not be mistaken for a query
72+
// separator — the real query is the one trailing the path.
73+
const request = `${path.join(dosFixtures, "a")}?foo=bar`;
74+
await expect(resolver.resolvePromise({}, fixtures, request)).resolves.toBe(
75+
`${path.join(dosFixtures, "a.js")}?foo=bar`,
76+
);
77+
});
78+
79+
test("preserves a fragment on a \\\\?\\ request", async () => {
80+
const request = `${path.join(dosFixtures, "a")}#frag`;
81+
await expect(resolver.resolvePromise({}, fixtures, request)).resolves.toBe(
82+
`${path.join(dosFixtures, "a.js")}#frag`,
83+
);
84+
});
85+
86+
test("rejects a missing file under a DOS device context", async () => {
87+
await expect(
88+
resolver.resolvePromise({}, dosFixtures, "./does-not-exist"),
89+
).rejects.toThrow(/Can't resolve/);
90+
});
91+
92+
test("locates the nearest package.json when resolving through \\\\?\\", (done) => {
93+
// Uses the callback form because the `request` (third callback arg)
94+
// carries `descriptionFilePath`, which the promise form drops.
95+
resolver.resolve(
96+
{},
97+
path.join(dosFixtures, "foo"),
98+
".",
99+
{},
100+
(err, result, request) => {
101+
if (err) return done(err);
102+
expect(result).toBe(path.join(dosFixtures, "foo", "index.js"));
103+
// The description-file walk must terminate — if `cdUp` didn't
104+
// treat bare `\` as a root, this callback would never fire.
105+
expect(/** @type {any} */ (request).descriptionFilePath).toBe(
106+
path.join(dosFixtures, "foo", "package.json"),
107+
);
108+
done();
109+
},
110+
);
111+
});
112+
});

test/identifier.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,48 @@ describe("identifier", () => {
146146
run(tests);
147147
});
148148

149+
describe("parse identifier. DOS device paths", () => {
150+
/** @type {TestSuite[]} */
151+
const tests = [
152+
// The literal `?` inside `\\?\` is part of the prefix, not a query
153+
// separator. Same for the `.` in `\\.\`.
154+
{
155+
input: "\\\\?\\C:\\foo",
156+
expected: ["\\\\?\\C:\\foo", "", ""],
157+
},
158+
{
159+
input: "\\\\.\\C:\\foo",
160+
expected: ["\\\\.\\C:\\foo", "", ""],
161+
},
162+
{
163+
input: "\\\\?\\UNC\\server\\share\\file.js",
164+
expected: ["\\\\?\\UNC\\server\\share\\file.js", "", ""],
165+
},
166+
// Query/fragment past the prefix are still parsed normally.
167+
{
168+
input: "\\\\?\\C:\\foo?bar=1",
169+
expected: ["\\\\?\\C:\\foo", "?bar=1", ""],
170+
},
171+
{
172+
input: "\\\\?\\C:\\foo#frag",
173+
expected: ["\\\\?\\C:\\foo", "", "#frag"],
174+
},
175+
{
176+
input: "\\\\?\\C:\\foo?q=1#f",
177+
expected: ["\\\\?\\C:\\foo", "?q=1", "#f"],
178+
},
179+
// `\\foo` (plain UNC-ish, not a DOS device path) should not trigger
180+
// the prefix skip — its `?` would still be a query separator. We
181+
// don't have one to test that the fallback still works unchanged.
182+
{
183+
input: "\\\\server\\share\\file.js",
184+
expected: ["\\\\server\\share\\file.js", "", ""],
185+
},
186+
];
187+
188+
run(tests);
189+
});
190+
149191
describe("parse identifier. malformed inputs", () => {
150192
it("returns null for a single null-byte input (regex no-match)", () => {
151193
expect(parseIdentifier("\0")).toBeNull();

test/path.test.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,36 @@ describe("util/path getType", () => {
5353
expect(getType("9:/foo")).toBe(PathType.Normal);
5454
expect(getType("C:foo")).toBe(PathType.Normal);
5555
});
56+
57+
it("classifies DOS device paths as Windows-absolute", () => {
58+
// Win32 file namespace (\\?\)
59+
expect(getType("\\\\?\\C:\\foo")).toBe(PathType.AbsoluteWin);
60+
expect(getType("\\\\?\\C:\\foo\\bar")).toBe(PathType.AbsoluteWin);
61+
expect(getType("\\\\?\\UNC\\server\\share")).toBe(PathType.AbsoluteWin);
62+
expect(getType("\\\\?\\Volume{abc}\\f")).toBe(PathType.AbsoluteWin);
63+
// Win32 device namespace (\\.\)
64+
expect(getType("\\\\.\\C:\\foo")).toBe(PathType.AbsoluteWin);
65+
expect(getType("\\\\.\\PhysicalDrive0")).toBe(PathType.AbsoluteWin);
66+
// Bare prefix still counts — the filesystem will reject it, but
67+
// classifying it as Windows-absolute keeps downstream calls on
68+
// `path.win32` instead of silently falling back to posix.
69+
expect(getType("\\\\?\\")).toBe(PathType.AbsoluteWin);
70+
expect(getType("\\\\.\\")).toBe(PathType.AbsoluteWin);
71+
});
72+
73+
it("does not classify non-DOS backslash paths as Windows-absolute", () => {
74+
// Plain UNC (\\server\share) is not a DOS device path — don't
75+
// misclassify it (its handling is out of scope of this change).
76+
expect(getType("\\\\server\\share")).toBe(PathType.Normal);
77+
// Too short to match a DOS device prefix.
78+
expect(getType("\\\\?")).toBe(PathType.Normal);
79+
expect(getType("\\\\.")).toBe(PathType.Normal);
80+
// Second char must also be a backslash.
81+
expect(getType("\\?\\C:\\foo")).toBe(PathType.Normal);
82+
// Forward-slash variants aren't equivalent — Windows won't normalize
83+
// a DOS device path expressed with `/`.
84+
expect(getType("//?/C:/foo")).toBe(PathType.AbsolutePosix);
85+
});
5686
});
5787

5888
describe("util/path normalize", () => {
@@ -76,6 +106,14 @@ describe("util/path normalize", () => {
76106
it("normalizes normal paths through posix normalize", () => {
77107
expect(normalize("a/b/../c")).toBe("a/c");
78108
});
109+
110+
it("normalizes DOS device paths via win32", () => {
111+
expect(normalize("\\\\?\\C:\\foo\\..\\bar")).toBe("\\\\?\\C:\\bar");
112+
expect(normalize("\\\\.\\C:\\foo\\..\\bar")).toBe("\\\\.\\C:\\bar");
113+
expect(normalize("\\\\?\\UNC\\server\\share\\a\\..\\b")).toBe(
114+
"\\\\?\\UNC\\server\\share\\b",
115+
);
116+
});
79117
});
80118

81119
describe("util/path join", () => {
@@ -101,6 +139,13 @@ describe("util/path join", () => {
101139
it("joins rooted windows-style paths", () => {
102140
expect(join("C:\\a", "b")).toBe("C:\\a\\b");
103141
});
142+
143+
it("joins DOS device paths with win32 semantics", () => {
144+
expect(join("\\\\?\\C:\\a", "b")).toBe("\\\\?\\C:\\a\\b");
145+
expect(join("\\\\.\\C:\\a", "b")).toBe("\\\\.\\C:\\a\\b");
146+
// Absolute DOS device request wins over any root.
147+
expect(join("/posix/root", "\\\\?\\C:\\c")).toBe("\\\\?\\C:\\c");
148+
});
104149
});
105150

106151
describe("util/path dirname", () => {
@@ -112,6 +157,14 @@ describe("util/path dirname", () => {
112157
it("computes windows dirname for windows absolute paths", () => {
113158
expect(dirname("C:\\foo\\bar")).toBe("C:\\foo");
114159
});
160+
161+
it("computes windows dirname for DOS device paths", () => {
162+
expect(dirname("\\\\?\\C:\\foo\\bar")).toBe("\\\\?\\C:\\foo");
163+
expect(dirname("\\\\.\\C:\\foo\\bar")).toBe("\\\\.\\C:\\foo");
164+
expect(dirname("\\\\?\\UNC\\server\\share\\a\\b")).toBe(
165+
"\\\\?\\UNC\\server\\share\\a",
166+
);
167+
});
115168
});
116169

117170
describe("util/path cachedJoin", () => {

0 commit comments

Comments
 (0)