diff --git a/lib/escape.js b/lib/escape.js deleted file mode 100644 index 9aca8bd..0000000 --- a/lib/escape.js +++ /dev/null @@ -1,68 +0,0 @@ -'use strict' - -// eslint-disable-next-line max-len -// this code adapted from: https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ -const cmd = (input, doubleEscape) => { - if (!input.length) { - return '""' - } - - let result - if (!/[ \t\n\v"]/.test(input)) { - result = input - } else { - result = '"' - for (let i = 0; i <= input.length; ++i) { - let slashCount = 0 - while (input[i] === '\\') { - ++i - ++slashCount - } - - if (i === input.length) { - result += '\\'.repeat(slashCount * 2) - break - } - - if (input[i] === '"') { - result += '\\'.repeat(slashCount * 2 + 1) - result += input[i] - } else { - result += '\\'.repeat(slashCount) - result += input[i] - } - } - result += '"' - } - - // and finally, prefix shell meta chars with a ^ - result = result.replace(/[ !%^&()<>|"]/g, '^$&') - if (doubleEscape) { - result = result.replace(/[ !%^&()<>|"]/g, '^$&') - } - - return result -} - -const sh = (input) => { - if (!input.length) { - return `''` - } - - if (!/[\t\n\r "#$&'()*;<>?\\`|~]/.test(input)) { - return input - } - - // replace single quotes with '\'' and wrap the whole result in a fresh set of quotes - const result = `'${input.replace(/'/g, `'\\''`)}'` - // if the input string already had single quotes around it, clean those up - .replace(/^(?:'')+(?!$)/, '') - .replace(/\\'''/g, `\\'`) - - return result -} - -module.exports = { - cmd, - sh, -} diff --git a/lib/make-spawn-args.js b/lib/make-spawn-args.js index 5b06db3..4ec0a88 100644 --- a/lib/make-spawn-args.js +++ b/lib/make-spawn-args.js @@ -1,16 +1,13 @@ /* eslint camelcase: "off" */ -const isWindows = require('./is-windows.js') const setPATH = require('./set-path.js') const { resolve } = require('path') -const which = require('which') const npm_config_node_gyp = require.resolve('node-gyp/bin/node-gyp.js') -const escape = require('./escape.js') const makeSpawnArgs = options => { const { event, path, - scriptShell = isWindows ? process.env.ComSpec || 'cmd' : 'sh', + scriptShell = true, binPaths, env = {}, stdio, @@ -29,55 +26,15 @@ const makeSpawnArgs = options => { npm_config_node_gyp, }) - let doubleEscape = false - const isCmd = /(?:^|\\)cmd(?:\.exe)?$/i.test(scriptShell) - if (isCmd) { - let initialCmd = '' - let insideQuotes = false - for (let i = 0; i < cmd.length; ++i) { - const char = cmd.charAt(i) - if (char === ' ' && !insideQuotes) { - break - } - - initialCmd += char - if (char === '"' || char === "'") { - insideQuotes = !insideQuotes - } - } - - let pathToInitial - try { - pathToInitial = which.sync(initialCmd, { - path: spawnEnv.path, - pathext: spawnEnv.pathext, - }).toLowerCase() - } catch (err) { - pathToInitial = initialCmd.toLowerCase() - } - - doubleEscape = pathToInitial.endsWith('.cmd') || pathToInitial.endsWith('.bat') - } - - let script = cmd - for (const arg of args) { - script += isCmd - ? ` ${escape.cmd(arg, doubleEscape)}` - : ` ${escape.sh(arg)}` - } - const spawnArgs = isCmd - ? ['/d', '/s', '/c', script] - : ['-c', '--', script] - const spawnOpts = { env: spawnEnv, stdioString, stdio, cwd: path, - ...(isCmd ? { windowsVerbatimArguments: true } : {}), + shell: scriptShell, } - return [scriptShell, spawnArgs, spawnOpts] + return [cmd, args, spawnOpts] } module.exports = makeSpawnArgs diff --git a/package.json b/package.json index 2b4494f..29197ed 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@npmcli/node-gyp": "^3.0.0", - "@npmcli/promise-spawn": "^5.0.0", + "@npmcli/promise-spawn": "^6.0.0", "node-gyp": "^9.0.0", "read-package-json-fast": "^3.0.0", "which": "^2.0.2" diff --git a/test/escape.js b/test/escape.js deleted file mode 100644 index ca4c818..0000000 --- a/test/escape.js +++ /dev/null @@ -1,123 +0,0 @@ -'use strict' - -const { writeFileSync: writeFile } = require('fs') -const { join } = require('path') -const t = require('tap') -const promiseSpawn = require('@npmcli/promise-spawn') - -const escape = require('../lib/escape.js') -const isWindows = process.platform === 'win32' - -t.test('sh', (t) => { - const expectations = [ - ['', `''`], - ['test', 'test'], - ['test words', `'test words'`], - ['$1', `'$1'`], - ['"$1"', `'"$1"'`], - [`'$1'`, `\\''$1'\\'`], - ['\\$1', `'\\$1'`], - ['--arg="$1"', `'--arg="$1"'`], - ['--arg=npm exec -c "$1"', `'--arg=npm exec -c "$1"'`], - [`--arg=npm exec -c '$1'`, `'--arg=npm exec -c '\\''$1'\\'`], - [`'--arg=npm exec -c "$1"'`, `\\''--arg=npm exec -c "$1"'\\'`], - ] - - for (const [input, expectation] of expectations) { - t.equal(escape.sh(input), expectation, - `expected to escape \`${input}\` to \`${expectation}\``) - } - - t.test('integration', { skip: isWindows && 'posix only' }, async (t) => { - for (const [input] of expectations) { - const script = `node -p process.argv[1] -- ${escape.sh(input)}` - const p = await promiseSpawn('sh', ['-c', '--', script], { stdioString: true }) - const stdout = p.stdout.trim() - t.equal(stdout, input, `expected \`${stdout}\` to equal \`${input}\``) - } - - t.end() - }) - - t.end() -}) - -t.test('cmd', (t) => { - const expectations = [ - ['', '""'], - ['test', 'test'], - ['%PATH%', '^%PATH^%'], - ['%PATH%', '^^^%PATH^^^%', true], - ['"%PATH%"', '^"\\^"^%PATH^%\\^"^"'], - ['"%PATH%"', '^^^"\\^^^"^^^%PATH^^^%\\^^^"^^^"', true], - [`'%PATH%'`, `'^%PATH^%'`], - [`'%PATH%'`, `'^^^%PATH^^^%'`, true], - ['\\%PATH%', '\\^%PATH^%'], - ['\\%PATH%', '\\^^^%PATH^^^%', true], - ['--arg="%PATH%"', '^"--arg=\\^"^%PATH^%\\^"^"'], - ['--arg="%PATH%"', '^^^"--arg=\\^^^"^^^%PATH^^^%\\^^^"^^^"', true], - ['--arg=npm exec -c "%PATH%"', '^"--arg=npm^ exec^ -c^ \\^"^%PATH^%\\^"^"'], - ['--arg=npm exec -c "%PATH%"', - '^^^"--arg=npm^^^ exec^^^ -c^^^ \\^^^"^^^%PATH^^^%\\^^^"^^^"', true], - [`--arg=npm exec -c '%PATH%'`, `^"--arg=npm^ exec^ -c^ '^%PATH^%'^"`], - [`--arg=npm exec -c '%PATH%'`, `^^^"--arg=npm^^^ exec^^^ -c^^^ '^^^%PATH^^^%'^^^"`, true], - [`'--arg=npm exec -c "%PATH%"'`, `^"'--arg=npm^ exec^ -c^ \\^"^%PATH^%\\^"'^"`], - [`'--arg=npm exec -c "%PATH%"'`, - `^^^"'--arg=npm^^^ exec^^^ -c^^^ \\^^^"^^^%PATH^^^%\\^^^"'^^^"`, true], - ['"C:\\Program Files\\test.bat"', '^"\\^"C:\\Program^ Files\\test.bat\\^"^"'], - ['"C:\\Program Files\\test.bat"', '^^^"\\^^^"C:\\Program^^^ Files\\test.bat\\^^^"^^^"', true], - ['"C:\\Program Files\\test%.bat"', '^"\\^"C:\\Program^ Files\\test^%.bat\\^"^"'], - ['"C:\\Program Files\\test%.bat"', - '^^^"\\^^^"C:\\Program^^^ Files\\test^^^%.bat\\^^^"^^^"', true], - ['% % %', '^"^%^ ^%^ ^%^"'], - ['% % %', '^^^"^^^%^^^ ^^^%^^^ ^^^%^^^"', true], - ['hello^^^^^^', 'hello^^^^^^^^^^^^'], - ['hello^^^^^^', 'hello^^^^^^^^^^^^^^^^^^^^^^^^', true], - ['hello world', '^"hello^ world^"'], - ['hello world', '^^^"hello^^^ world^^^"', true], - ['hello"world', '^"hello\\^"world^"'], - ['hello"world', '^^^"hello\\^^^"world^^^"', true], - ['hello""world', '^"hello\\^"\\^"world^"'], - ['hello""world', '^^^"hello\\^^^"\\^^^"world^^^"', true], - ['hello\\world', 'hello\\world'], - ['hello\\world', 'hello\\world', true], - ['hello\\\\world', 'hello\\\\world'], - ['hello\\\\world', 'hello\\\\world', true], - ['hello\\"world', '^"hello\\\\\\^"world^"'], - ['hello\\"world', '^^^"hello\\\\\\^^^"world^^^"', true], - ['hello\\\\"world', '^"hello\\\\\\\\\\^"world^"'], - ['hello\\\\"world', '^^^"hello\\\\\\\\\\^^^"world^^^"', true], - ['hello world\\', '^"hello^ world\\\\^"'], - ['hello world\\', '^^^"hello^^^ world\\\\^^^"', true], - ['hello %PATH%', '^"hello^ ^%PATH^%^"'], - ['hello %PATH%', '^^^"hello^^^ ^^^%PATH^^^%^^^"', true], - ] - - for (const [input, expectation, double] of expectations) { - const msg = `expected to${double ? ' double' : ''} escape \`${input}\` to \`${expectation}\`` - t.equal(escape.cmd(input, double), expectation, msg) - } - - t.test('integration', { skip: !isWindows && 'Windows only' }, async (t) => { - const dir = t.testdir() - const shimFile = join(dir, 'shim.cmd') - const shim = `@echo off\nnode -p process.argv[1] -- %*` - writeFile(shimFile, shim) - - for (const [input,, double] of expectations) { - const script = double - ? `${escape.cmd(shimFile)} ${escape.cmd(input, double)}` - : `node -p process.argv[1] -- ${escape.cmd(input)}` - const p = await promiseSpawn('cmd', ['/d', '/s', '/c', script], { - stdioString: true, - windowsVerbatimArguments: true, - }) - const stdout = p.stdout.trim() - t.equal(stdout, input, `expected \`${stdout}\` to equal \`${input}\``) - } - - t.end() - }) - - t.end() -}) diff --git a/test/make-spawn-args.js b/test/make-spawn-args.js index 1512be4..0801597 100644 --- a/test/make-spawn-args.js +++ b/test/make-spawn-args.js @@ -10,17 +10,6 @@ if (!process.env.__FAKE_TESTING_PLATFORM__) { } }) } -const whichPaths = new Map() -const which = { - sync: (req) => { - if (whichPaths.has(req)) { - return whichPaths.get(req) - } - - throw new Error('not found') - }, -} - const { dirname } = require('path') const resolve = (...args) => { const root = isWindows ? 'C:\\Temp' : '/tmp' @@ -28,7 +17,6 @@ const resolve = (...args) => { } const makeSpawnArgs = requireInject('../lib/make-spawn-args.js', { - which, path: { dirname, resolve, @@ -37,23 +25,20 @@ const makeSpawnArgs = requireInject('../lib/make-spawn-args.js', { if (isWindows) { t.test('windows', t => { - // with no ComSpec - delete process.env.ComSpec - whichPaths.set('cmd', 'C:\\Windows\\System32\\cmd.exe') + const comSpec = process.env.ComSpec + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' t.teardown(() => { - whichPaths.delete('cmd') + process.env.ComSpec = comSpec }) t.test('simple script', (t) => { - const [shell, args, opts] = makeSpawnArgs({ + const [cmd, args, opts] = makeSpawnArgs({ event: 'event', path: 'path', cmd: 'script "quoted parameter"; second command', }) - t.equal(shell, 'cmd', 'default shell applies') - t.strictSame(args, ['/d', '/s', '/c', - 'script "quoted parameter"; second command', - ], 'got expected args') + t.equal(cmd, 'script "quoted parameter"; second command') + t.strictSame(args, []) t.hasStrict(opts, { env: { npm_package_json: 'C:\\Temp\\path\\package.json', @@ -61,24 +46,22 @@ if (isWindows) { npm_lifecycle_script: 'script "quoted parameter"; second command', npm_config_node_gyp: require.resolve('node-gyp/bin/node-gyp.js'), }, + shell: true, stdio: undefined, cwd: 'path', - windowsVerbatimArguments: true, }, 'got expected options') t.end() }) t.test('event with invalid characters runs', (t) => { - const [shell, args, opts] = makeSpawnArgs({ + const [cmd, args, opts] = makeSpawnArgs({ event: 'event<:>\x03', // everything after the word "event" is invalid path: 'path', cmd: 'script "quoted parameter"; second command', }) - t.equal(shell, 'cmd', 'default shell applies') - t.strictSame(args, ['/d', '/s', '/c', - 'script "quoted parameter"; second command', - ], 'got expected args') + t.equal(cmd, 'script "quoted parameter"; second command') + t.strictSame(args, []) t.hasStrict(opts, { env: { npm_package_json: 'C:\\Temp\\path\\package.json', @@ -86,153 +69,56 @@ if (isWindows) { npm_lifecycle_script: 'script "quoted parameter"; second command', npm_config_node_gyp: require.resolve('node-gyp/bin/node-gyp.js'), }, + shell: true, stdio: undefined, cwd: 'path', - windowsVerbatimArguments: true, }, 'got expected options') t.end() }) - t.test('with a funky ComSpec', (t) => { - process.env.ComSpec = 'blrorp' - whichPaths.set('blrorp', '/bin/blrorp') - t.teardown(() => { - whichPaths.delete('blrorp') - delete process.env.ComSpec - }) - const [shell, args, opts] = makeSpawnArgs({ + t.test('with a funky scriptShell', (t) => { + const [cmd, args, opts] = makeSpawnArgs({ event: 'event', path: 'path', cmd: 'script "quoted parameter"; second command', + scriptShell: 'blrpop', }) - t.equal(shell, 'blrorp', 'used ComSpec as default shell') - t.strictSame(args, ['-c', '--', 'script "quoted parameter"; second command'], - 'got expected args') + t.equal(cmd, 'script "quoted parameter"; second command') + t.strictSame(args, []) t.hasStrict(opts, { env: { npm_package_json: 'C:\\Temp\\path\\package.json', npm_lifecycle_event: 'event', npm_lifecycle_script: 'script "quoted parameter"; second command', }, + shell: 'blrpop', stdio: undefined, cwd: 'path', - windowsVerbatimArguments: undefined, }, 'got expected options') t.end() }) t.test('with cmd.exe as scriptShell', (t) => { - const [shell, args, opts] = makeSpawnArgs({ + const [cmd, args, opts] = makeSpawnArgs({ event: 'event', path: 'path', cmd: 'script', args: ['"quoted parameter";', 'second command'], scriptShell: 'cmd.exe', }) - t.equal(shell, 'cmd.exe', 'kept cmd.exe') - t.strictSame(args, ['/d', '/s', '/c', - 'script ^"\\^"quoted^ parameter\\^";^" ^"second^ command^"', - ], 'got expected args') + t.equal(cmd, 'script') + t.strictSame(args, ['"quoted parameter";', 'second command']) t.hasStrict(opts, { env: { npm_package_json: 'C:\\Temp\\path\\package.json', npm_lifecycle_event: 'event', npm_lifecycle_script: 'script', }, + shell: 'cmd.exe', stdio: undefined, cwd: 'path', - windowsVerbatimArguments: true, - }, 'got expected options') - - t.end() - }) - - t.test('single escapes when initial command is not a batch file', (t) => { - whichPaths.set('script', '/path/script.exe') - t.teardown(() => whichPaths.delete('script')) - - const [shell, args, opts] = makeSpawnArgs({ - event: 'event', - path: 'path', - cmd: 'script', - args: ['"quoted parameter";', 'second command'], - }) - t.equal(shell, 'cmd', 'default shell applies') - t.strictSame(args, ['/d', '/s', '/c', - 'script ^"\\^"quoted^ parameter\\^";^" ^"second^ command^"', - ], 'got expected args') - t.hasStrict(opts, { - env: { - npm_package_json: 'C:\\Temp\\path\\package.json', - npm_lifecycle_event: 'event', - npm_lifecycle_script: 'script', - npm_config_node_gyp: require.resolve('node-gyp/bin/node-gyp.js'), - }, - stdio: undefined, - cwd: 'path', - windowsVerbatimArguments: true, - }, 'got expected options') - - t.end() - }) - - t.test('double escapes when initial command is a batch file', (t) => { - whichPaths.set('script', '/path/script.cmd') - t.teardown(() => whichPaths.delete('script')) - - const [shell, args, opts] = makeSpawnArgs({ - event: 'event', - path: 'path', - cmd: 'script', - args: ['"quoted parameter";', 'second command'], - }) - t.equal(shell, 'cmd', 'default shell applies') - t.strictSame(args, ['/d', '/s', '/c', - 'script ^^^"\\^^^"quoted^^^ parameter\\^^^";^^^" ^^^"second^^^ command^^^"', - ], 'got expected args') - t.hasStrict(opts, { - env: { - npm_package_json: 'C:\\Temp\\path\\package.json', - npm_lifecycle_event: 'event', - npm_lifecycle_script: 'script', - npm_config_node_gyp: require.resolve('node-gyp/bin/node-gyp.js'), - }, - stdio: undefined, - cwd: 'path', - windowsVerbatimArguments: true, - }, 'got expected options') - - t.end() - }) - - t.test('correctly identifies initial cmd with spaces', (t) => { - // we do blind lookups in our test fixture here, however node-which - // will remove surrounding quotes - whichPaths.set('"my script"', '/path/script.cmd') - t.teardown(() => whichPaths.delete('my script')) - - const [shell, args, opts] = makeSpawnArgs({ - event: 'event', - path: 'path', - cmd: '"my script"', - args: ['"quoted parameter";', 'second command'], - }) - t.equal(shell, 'cmd', 'default shell applies') - t.strictSame(args, ['/d', '/s', '/c', - '"my script" ^^^"\\^^^"quoted^^^ parameter\\^^^";^^^" ^^^"second^^^ command^^^"', - ], 'got expected args') - t.hasStrict(opts, { - env: { - npm_package_json: 'C:\\Temp\\path\\package.json', - npm_lifecycle_event: 'event', - npm_lifecycle_script: '"my script"', - npm_config_node_gyp: require.resolve('node-gyp/bin/node-gyp.js'), - }, - stdio: undefined, - cwd: 'path', - windowsVerbatimArguments: true, }, 'got expected options') t.end() @@ -242,27 +128,22 @@ if (isWindows) { }) } else { t.test('posix', t => { - whichPaths.set('sh', '/bin/sh') - t.teardown(() => { - whichPaths.delete('sh') - }) - t.test('simple script', (t) => { - const [shell, args, opts] = makeSpawnArgs({ + const [cmd, args, opts] = makeSpawnArgs({ event: 'event', path: 'path', cmd: 'script', args: ['"quoted parameter";', 'second command'], }) - t.equal(shell, 'sh', 'defaults to sh') - t.strictSame(args, ['-c', '--', `script '"quoted parameter";' 'second command'`], - 'got expected args') + t.equal(cmd, 'script') + t.strictSame(args, ['"quoted parameter";', 'second command']) t.hasStrict(opts, { env: { npm_package_json: '/tmp/path/package.json', npm_lifecycle_event: 'event', npm_lifecycle_script: 'script', }, + shell: true, stdio: undefined, cwd: 'path', windowsVerbatimArguments: undefined, @@ -272,21 +153,21 @@ if (isWindows) { }) t.test('event with invalid characters runs', (t) => { - const [shell, args, opts] = makeSpawnArgs({ + const [cmd, args, opts] = makeSpawnArgs({ event: 'event<:>/\x04', path: 'path', cmd: 'script', args: ['"quoted parameter";', 'second command'], }) - t.equal(shell, 'sh', 'defaults to sh') - t.strictSame(args, ['-c', '--', `script '"quoted parameter";' 'second command'`], - 'got expected args') + t.equal(cmd, 'script') + t.strictSame(args, ['"quoted parameter";', 'second command']) t.hasStrict(opts, { env: { npm_package_json: '/tmp/path/package.json', npm_lifecycle_event: 'event<:>/\x04', npm_lifecycle_script: 'script', }, + shell: true, stdio: undefined, cwd: 'path', windowsVerbatimArguments: undefined, @@ -295,32 +176,6 @@ if (isWindows) { t.end() }) - t.test('can use cmd.exe', (t) => { - // test that we can explicitly run in cmd.exe, even on posix systems - // relevant for running under WSL - const [shell, args, opts] = makeSpawnArgs({ - event: 'event', - path: 'path', - cmd: 'script "quoted parameter"; second command', - scriptShell: 'cmd.exe', - }) - t.equal(shell, 'cmd.exe', 'kept cmd.exe') - t.strictSame(args, ['/d', '/s', '/c', 'script "quoted parameter"; second command'], - 'got expected args') - t.hasStrict(opts, { - env: { - npm_package_json: '/tmp/path/package.json', - npm_lifecycle_event: 'event', - npm_lifecycle_script: 'script "quoted parameter"; second command', - }, - stdio: undefined, - cwd: 'path', - windowsVerbatimArguments: true, - }, 'got expected options') - - t.end() - }) - t.end() }) }