From fabdd4e90c4738cf6bd77fd990215ec8e9c51a57 Mon Sep 17 00:00:00 2001 From: David Castro <117443013+Dcastroro@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:29:46 +0200 Subject: [PATCH] fix: resolve Windows worker PATH names case-insensitively --- lib/index.js | 14 ++++++- test/path-env.js | 105 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 test/path-env.js diff --git a/lib/index.js b/lib/index.js index 2fd358b..69cbcfa 100644 --- a/lib/index.js +++ b/lib/index.js @@ -15,8 +15,20 @@ const rRel = new RegExp(`^\\.${rSlash.source}`) const getNotFoundError = (cmd) => Object.assign(new Error(`not found: ${cmd}`), { code: 'ENOENT' }) +const getPathEnv = () => { + if (!isWindows || process.env.PATH !== undefined) { + return process.env.PATH + } + // Worker environments are case-sensitive even on Windows. + for (const key of Object.keys(process.env)) { + if (key.toLowerCase() === 'path') { + return process.env[key] + } + } +} + const getPathInfo = (cmd, { - path: optPath = process.env.PATH, + path: optPath = getPathEnv(), pathExt: optPathExt = process.env.PATHEXT, delimiter: optDelimiter = delimiter, }) => { diff --git a/test/path-env.js b/test/path-env.js new file mode 100644 index 0000000..991a1c4 --- /dev/null +++ b/test/path-env.js @@ -0,0 +1,105 @@ +const t = require('tap') +const { join } = require('path') +const { Worker } = require('worker_threads') +const { once } = require('events') + +const nativeWindows = process.platform === 'win32' +const whichFile = require.resolve('..') + +const loadWhich = (t, platform, env, executable) => { + const originalEnv = process.env + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + process.env = env + Object.defineProperty(process, 'platform', { ...originalPlatform, value: platform }) + t.teardown(() => { + process.env = originalEnv + Object.defineProperty(process, 'platform', originalPlatform) + }) + const matches = p => p === executable + return t.mock('..', { isexe: { isexe: async p => matches(p), sync: matches } }) +} + +for (const key of ['Path', 'path', 'pAtH']) { + t.test(`Windows resolves a command from ${key} when PATH is absent`, async t => { + const dir = t.testdir({ 'fixture.cmd': '@echo fixture\n' }) + const file = join(dir, 'fixture.cmd') + const which = loadWhich(t, 'win32', { OTHER: 'fixture', [key]: dir, PATHEXT: '.CMD' }, file) + t.equal(await which('fixture'), file, 'async') + t.equal(which.sync('fixture'), file, 'sync') + }) +} + +t.test('Windows preserves an explicitly present PATH, including an empty value', async t => { + const dir = t.testdir({ 'fixture.cmd': '@echo fixture\n' }) + const file = join(dir, 'fixture.cmd') + const env = { PATH: dir, Path: 'ignored', PATHEXT: '.CMD' } + const which = loadWhich(t, 'win32', env, file) + t.equal(await which('fixture'), file) + t.equal(which.sync('fixture'), file) + env.PATH = '' + env.Path = dir + t.equal(await which('fixture', { nothrow: true }), null, 'empty PATH does not fall back') + t.equal(which.sync('fixture', { nothrow: true }), null) +}) + +t.test('an explicit path option overrides the Windows environment', async t => { + const dir = t.testdir({ 'fixture.cmd': '@echo fixture\n' }) + const file = join(dir, 'fixture.cmd') + const which = loadWhich(t, 'win32', { Path: 'ignored', PATHEXT: '.CMD' }, file) + t.equal(await which('fixture', { path: dir }), file) + t.equal(which.sync('fixture', { path: dir }), file) + t.equal(await which('fixture', { path: '', nothrow: true }), null) + t.equal(which.sync('fixture', { path: '', nothrow: true }), null) +}) + +t.test('POSIX ignores differently cased environment names', async t => { + const dir = t.testdir({ fixture: 'fixture\n' }) + const file = join(dir, 'fixture') + const env = { Path: dir, path: dir } + const which = loadWhich(t, 'linux', env, file) + t.equal(await which('fixture', { nothrow: true }), null) + t.equal(which.sync('fixture', { nothrow: true }), null) + env.PATH = dir + t.equal(await which('fixture'), file, 'uppercase PATH still works') + t.equal(which.sync('fixture'), file) +}) + +t.test('Windows with no PATH spelling retains not-found behavior', async t => { + const which = loadWhich(t, 'win32', { OTHER: 'fixture', PATHEXT: '.CMD' }, '') + await t.rejects(which('fixture'), { code: 'ENOENT' }) + t.throws(() => which.sync('fixture'), { code: 'ENOENT' }) +}) + +t.test('the Windows fallback reads the current environment on each call', async t => { + const dir = t.testdir({ 'fixture.cmd': '@echo fixture\n' }) + const file = join(dir, 'fixture.cmd') + const env = { Path: dir, PATHEXT: '.CMD' } + const which = loadWhich(t, 'win32', env, file) + t.equal(await which('fixture'), file) + delete env.Path + env.pAtH = 'not-the-fixture-directory' + t.equal(which.sync('fixture', { nothrow: true }), null) + env.pAtH = dir + t.equal(which.sync('fixture'), file) +}) + +t.test('a real Windows worker resolves Path without inheriting the parent environment', { + skip: !nativeWindows && 'requires Windows worker environment semantics', +}, async t => { + const dir = t.testdir({ 'fixture.cmd': '@echo fixture\n' }) + const file = join(dir, 'fixture.cmd') + const worker = new Worker(` + const { parentPort } = require('worker_threads') + const which = require(${JSON.stringify(whichFile)}) + ;(async () => { + parentPort.postMessage({ + upperPathAbsent: process.env.PATH === undefined, + async: await which('fixture.cmd', { nothrow: true }), + sync: which.sync('fixture.cmd', { nothrow: true }), + }) + })().catch(error => { throw error }) + `, { eval: true, env: { Path: dir, PATHEXT: '.CMD' } }) + t.teardown(() => worker.terminate()) + const [result] = await once(worker, 'message') + t.same(result, { upperPathAbsent: true, async: file, sync: file }) +})