From 4760762b48761dc70846bace4ebaf963999284aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 18:24:37 +0000 Subject: [PATCH] fix: treat Windows App Execution Aliases as executable WindowsApps aliases such as mspaint.exe raise EACCES from fs.stat, so isexe reports them as non-executable. After an isexe miss or EACCES/EPERM, treat Microsoft\WindowsApps paths as executable when fs.access succeeds. Co-authored-by: David --- lib/index.js | 63 +++++++++++++++++++++++- test/index.js | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index 2fd358b..29d638b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,8 +1,67 @@ +const { accessSync, constants } = require('fs') +const { access } = require('fs/promises') const { isexe, sync: isexeSync } = require('isexe') const { join, delimiter, sep, posix } = require('path') const isWindows = process.platform === 'win32' +// Windows App Execution Aliases (e.g. %LOCALAPPDATA%\Microsoft\WindowsApps\mspaint.exe) +// look like .exe files and can be launched, but fs.stat() raises EACCES. isexe then +// returns false (or throws on older versions). fs.access() succeeds on the same path. +const rWindowsApps = /(?:^|[\\/])Microsoft[\\/]WindowsApps[\\/]/i + +const isDenied = (er) => er && (er.code === 'EACCES' || er.code === 'EPERM') + +const isAppExecutionAlias = async (p) => { + if (!isWindows || !rWindowsApps.test(p)) { + return false + } + try { + await access(p, constants.F_OK) + return true + } catch { + return false + } +} + +const isAppExecutionAliasSync = (p) => { + if (!isWindows || !rWindowsApps.test(p)) { + return false + } + try { + accessSync(p, constants.F_OK) + return true + } catch { + return false + } +} + +const isExecutable = async (p, pathExtExe) => { + try { + if (await isexe(p, { pathExt: pathExtExe, ignoreErrors: true })) { + return true + } + } catch (er) { + if (!isDenied(er)) { + throw er + } + } + return isAppExecutionAlias(p) +} + +const isExecutableSync = (p, pathExtExe) => { + try { + if (isexeSync(p, { pathExt: pathExtExe, ignoreErrors: true })) { + return true + } + } catch (er) { + if (!isDenied(er)) { + throw er + } + } + return isAppExecutionAliasSync(p) +} + // used to check for slashed in commands passed in. always checks for the posix // seperator on all platforms, and checks for the current separator when not on // a posix platform. don't use the isWindows check for this since that is mocked @@ -56,7 +115,7 @@ const which = async (cmd, opt = {}) => { for (const ext of pathExt) { const withExt = p + ext - const is = await isexe(withExt, { pathExt: pathExtExe, ignoreErrors: true }) + const is = await isExecutable(withExt, pathExtExe) if (is) { if (!opt.all) { return withExt @@ -86,7 +145,7 @@ const whichSync = (cmd, opt = {}) => { for (const ext of pathExt) { const withExt = p + ext - const is = isexeSync(withExt, { pathExt: pathExtExe, ignoreErrors: true }) + const is = isExecutableSync(withExt, pathExtExe) if (is) { if (!opt.all) { return withExt diff --git a/test/index.js b/test/index.js index c77fed5..b350838 100644 --- a/test/index.js +++ b/test/index.js @@ -137,6 +137,135 @@ t.test('find all', async t => { }) }) +t.test('windows app execution aliases', async (t) => { + const fixture = t.testdir({ + Microsoft: { + WindowsApps: { + 'mspaint.exe': 'alias', + }, + }, + other: { + 'notepad.exe': 'real', + }, + }) + const apps = join(fixture, 'Microsoft', 'WindowsApps') + const mspaint = join(apps, 'mspaint.exe') + const otherDir = join(fixture, 'other') + + const runAlias = async (t, expect, { + platform = 'win32', + cmd = 'mspaint', + isexeImpl, + ...opt + } = {}) => { + Object.defineProperty(process, 'platform', { ...PLATFORM, value: platform }) + t.teardown(() => { + Object.defineProperty(process, 'platform', PLATFORM) + }) + + const which = t.mock('..', { + isexe: { + isexe: async (...args) => isexeImpl(...args), + sync: isexeImpl, + }, + }) + + if (expect?.code) { + await t.rejects(() => which(cmd, opt), expect, 'async rejects') + t.throws(() => which.sync(cmd, opt), expect, 'sync throws') + } else { + t.strictSame(await which(cmd, opt), expect, 'async') + t.strictSame(which.sync(cmd, opt), expect, 'sync') + } + } + + t.test('finds alias when isexe reports not executable', async (t) => { + await runAlias(t, mspaint, { + path: apps, + pathExt: '.exe', + isexeImpl: () => false, + }) + }) + + t.test('finds alias by absolute path', async (t) => { + await runAlias(t, mspaint, { + cmd: mspaint, + pathExt: '.exe', + isexeImpl: () => false, + }) + }) + + t.test('finds alias when isexe throws EACCES', async (t) => { + const denied = Object.assign(new Error('EACCES'), { code: 'EACCES' }) + await runAlias(t, mspaint, { + path: apps, + pathExt: '.exe', + isexeImpl: () => { + throw denied + }, + }) + }) + + t.test('finds alias when isexe throws EPERM', async (t) => { + const denied = Object.assign(new Error('EPERM'), { code: 'EPERM' }) + await runAlias(t, mspaint, { + path: apps, + pathExt: '.exe', + isexeImpl: () => { + throw denied + }, + }) + }) + + t.test('rethrows unexpected isexe errors', async (t) => { + const io = Object.assign(new Error('EIO'), { code: 'EIO' }) + await runAlias(t, { code: 'EIO' }, { + path: apps, + pathExt: '.exe', + isexeImpl: () => { + throw io + }, + }) + }) + + t.test('does not treat missing WindowsApps files as executable', async (t) => { + await runAlias(t, null, { + cmd: 'notepad', + path: apps, + pathExt: '.exe', + nothrow: true, + isexeImpl: () => false, + }) + }) + + t.test('does not apply fallback outside WindowsApps', async (t) => { + await runAlias(t, { code: 'ENOENT' }, { + cmd: 'notepad', + path: otherDir, + pathExt: '.exe', + isexeImpl: () => false, + }) + }) + + t.test('posix does not apply WindowsApps fallback', async (t) => { + await runAlias(t, { code: 'ENOENT' }, { + platform: 'posix', + cmd: mspaint, + isexeImpl: () => false, + }) + }) + + t.test('finds all aliases', async (t) => { + await runAlias(t, [mspaint], { + cmd: 'mspaint', + path: apps, + pathExt: '.EXE', + all: true, + isexeImpl: () => false, + }) + }) +}) + t.test('pathExt', async (t) => { const fixture = t.testdir({ 'foo.sh': 'echo foo\n' }) const foo = join(fixture, 'foo.sh')