Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down