Skip to content

Commit bac4890

Browse files
Merge pull request #1983 from Gentleman-Programming/fix/1978-pathext-any-extension
fix(installer): accept well-formed Windows PATHEXT entries such as Python .PY and .PYW
2 parents 80f1713 + 5e57a95 commit bac4890

2 files changed

Lines changed: 40 additions & 9 deletions

File tree

‎scripts/installer-windows.mjs‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,16 @@ function findWindowsCommand(env, name = "pnpm") {
195195
const path = value("path");
196196
const pathExt = value("pathext");
197197
if (typeof path !== "string" || typeof pathExt !== "string") throw new Error("Unknown Windows PATH/PATHEXT");
198-
const extensions = pathExt.toLowerCase().split(";");
199-
// .cpl is known because Windows PowerShell 5.1 appends it for its children, and
200-
// bootstrap.cmd starts this helper from there. Known is not accepted: a .cpl (or
201-
// any non-.cmd wrapper / non-.exe Node) found first still fails closed below.
202-
if (!extensions.length || new Set(extensions).size !== extensions.length || extensions.some((extension) => !/^\.(com|exe|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc|cpl)$/.test(extension))) throw new Error("Unknown Windows PATHEXT semantics");
198+
// Validate before lowercasing: case folding can map non-ASCII (U+212A KELVIN SIGN)
199+
// onto ASCII and make a different name look like a known extension.
200+
const entries = pathExt.split(";");
201+
if (entries.some((extension) => !/^\.[A-Za-z0-9]+$/.test(extension))) throw new Error("Unknown Windows PATHEXT semantics");
202+
const extensions = entries.map((extension) => extension.toLowerCase());
203+
// Any well-formed extension is resolved in its PATHEXT place, like CMD does:
204+
// Windows PowerShell 5.1 appends .CPL, Python adds .PY and .PYW (#1978), other
205+
// runtimes add their own. Resolved is not accepted: a .cpl, .py (or any
206+
// non-.cmd wrapper / non-.exe Node) found first still fails closed below.
207+
if (!extensions.length || new Set(extensions).size !== extensions.length || extensions.some((extension) => !/^\.[a-z0-9]+$/.test(extension))) throw new Error("Unknown Windows PATHEXT semantics");
203208
// CMD ignores empty entries (a trailing `;` is the Windows default); every
204209
// other non-absolute, quoted or UNC entry still fails closed.
205210
for (const directory of [process.cwd(), ...path.split(";").filter((entry) => entry !== "")]) {

‎tests/installer-windows-bootstrap.test.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,34 @@ test("a .cpl pnpm or node found first in PATH order fails closed instead of bein
274274
"pnpm.cmd wins over pnpm.cpl in the same directory; the missing node then stops it");
275275
} finally { f.cleanup(); }
276276
});
277-
test("Windows PATHEXT still rejects unknown, duplicate and empty extensions", async () => {
278-
for (const PATHEXT of [".EXE;.CMD;.XYZ", ".EXE;.CMD;.CPL;.cpl", ".EXE;;.CMD", ".EXE;CPL", ""]) {
277+
// Python adds .PY and .PYW to PATHEXT (#1978); Ruby, Perl and others add their
278+
// own. Any well-formed extension resolves in its PATHEXT place, and only a .cmd
279+
// npm shim with an .exe Node is ever accepted, so an unlisted one is never run.
280+
const pythonPathExt = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.PY;.PYW";
281+
test("Windows PATHEXT with Python's .PY and .PYW resolves an existing pnpm wrapper", async () => {
282+
const f = fixture();
283+
try {
284+
const commands = join(f.root, "commands"); pnpmWrapperDirectory(commands);
285+
for (const PATHEXT of [pythonPathExt, `${pythonPathExt};.CPL`]) {
286+
const existing = await ensureWindowsPnpm({ tools: f.root, env: { Path: commands, PATHEXT }, adapters: { storage: () => {}, process: cliProcess } });
287+
assert.equal(existing.acquired, false, PATHEXT);
288+
assert.equal(existing.command, join(commands, "node.exe"), PATHEXT);
289+
}
290+
} finally { f.cleanup(); }
291+
});
292+
test("a pnpm.py found first in PATH order fails closed and is never run", async () => {
293+
const f = fixture();
294+
try {
295+
const early = join(f.root, "early"); const commands = join(f.root, "commands");
296+
mkdirSync(early); pnpmWrapperDirectory(commands);
297+
writeFileSync(join(early, "pnpm.py"), "print('not pnpm')");
298+
const env = { Path: [early, commands].join(";"), PATHEXT: pythonPathExt };
299+
await assert.rejects(ensureWindowsPnpm({ tools: f.root, env, adapters: { storage: () => {}, download: () => { throw new Error("must not download"); }, process: () => { throw new Error("must not run"); } } }),
300+
(error: Error) => error.message === "Unknown pnpm wrapper; refusing replacement" && windowsBootstrapReason(error, "wrapper") === "wrapper-unknown (wrapper)");
301+
} finally { f.cleanup(); }
302+
});
303+
test("Windows PATHEXT still rejects duplicate, empty and malformed extensions", async () => {
304+
for (const PATHEXT of [".EXE;.CMD;.CPL;.cpl", ".EXE;.CMD;.PY;.py", ".EXE;;.CMD", ".EXE;CPL", "", ".", ".EXE;.E XE", ".EXE;.EXE*", ".EXE;..CMD", ".EXE;.CM\\D", ".EXE;.\u212A"]) {
279305
await assert.rejects(ensureWindowsPnpm({ tools: "C:\\tools", env: { Path: "C:\\fixture", PATHEXT } }), /Unknown Windows PATHEXT semantics/, PATHEXT);
280306
}
281307
});
@@ -503,9 +529,9 @@ test("Windows helper steps name each pnpm discovery, storage and proof phase", a
503529
findCommand: () => "C:\\fixture\\pnpm.cmd", storage: () => { throw Object.assign(new Error("Windows ACL evidence rejected"), { check: "target-owner" }); },
504530
} }), (error: { check?: string }) => error.check === "target-owner");
505531
assert.deepEqual(steps, ["pnpm-discovery", "wrapper-storage"]);
506-
// An extension outside the known Windows set still stops discovery and names the code.
532+
// A malformed PATHEXT (here a duplicate extension) still stops discovery and names the code.
507533
const unknown: string[] = [];
508-
await assert.rejects(ensureWindowsPnpm({ tools: "C:\\tools", env: { Path: "C:\\fixture", PATHEXT: ".EXE;.CMD;.XYZ" }, onStep: (step: string) => unknown.push(step) }), /Unknown Windows PATHEXT semantics/);
534+
await assert.rejects(ensureWindowsPnpm({ tools: "C:\\tools", env: { Path: "C:\\fixture", PATHEXT: ".EXE;.CMD;.cmd" }, onStep: (step: string) => unknown.push(step) }), /Unknown Windows PATHEXT semantics/);
509535
assert.deepEqual(unknown, ["pnpm-discovery"]);
510536
const helper = readFileSync(new URL("../scripts/installer-windows.mjs", import.meta.url), "utf8");
511537
const body = helper.slice(helper.indexOf("export async function ensureWindowsPnpm"), helper.indexOf("export async function bootstrapWindows"));

0 commit comments

Comments
 (0)