diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt new file mode 100644 index 0000000..be276a3 --- /dev/null +++ b/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,23 @@ +# cross-spawn + +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/package.json b/package.json index b3cd83a..8df52d7 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "node": ">=18" }, "files": [ - "dist" + "dist", + "THIRD-PARTY-LICENSES.txt" ], "scripts": { "build": "tsdown", diff --git a/src/main.ts b/src/main.ts index 443ee1a..e4d1903 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,10 +10,10 @@ import {cwd as getCwd} from 'node:process'; import {computeEnv} from './env.js'; import {combineStreams} from './stream.js'; import readline from 'node:readline'; -import {_parse} from 'cross-spawn'; +import {normalizeSpawnCommand} from './normalize.js'; import {NonZeroExitError} from './non-zero-exit-error.js'; -export {NonZeroExitError}; +export {NonZeroExitError, normalizeSpawnCommand}; const LINE_SEPARATOR_REGEX = /\r?\n/; @@ -312,7 +312,11 @@ export class ExecProcess implements Result { nodeOptions.env = computeEnv(cwd, nodeOptions.env); - const crossResult = _parse(this._command, this._args, nodeOptions); + const crossResult = normalizeSpawnCommand( + this._command, + this._args, + nodeOptions + ); const handle = spawn( crossResult.command, @@ -386,7 +390,7 @@ export function xSync( nodeOptions.env = computeEnv(cwd, nodeOptions.env); - const crossResult = _parse(command, args ?? [], nodeOptions); + const crossResult = normalizeSpawnCommand(command, args ?? [], nodeOptions); const spawnResult = spawnSync( crossResult.command, diff --git a/src/normalize.ts b/src/normalize.ts new file mode 100644 index 0000000..619eb99 --- /dev/null +++ b/src/normalize.ts @@ -0,0 +1,180 @@ +import {type SpawnOptions} from 'node:child_process'; +import {closeSync, openSync, readSync, statSync} from 'node:fs'; +import { + delimiter as pathDelimiter, + normalize as normalizePath, + resolve as resolvePath, + basename +} from 'node:path'; +import {cwd as getCwd} from 'node:process'; +import {getPathFromEnv} from './env.js'; + +// See http://www.robvanderwoude.com/escapechars.php +const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; +const shebangRegExp = /^#!\s*(.+)$/; +const isWindowsExecutableRegExp = /\.(?:com|exe)$/i; +const isNodeModulesCmdRegExp = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i; +const isWindows = process.platform === 'win32'; +const defaultPathExt = ['.EXE', '.CMD', '.BAT', '.COM']; + +interface NormalizedSpawnCommand { + command: string; + args: readonly string[]; + options: SpawnOptions; +} + +/** + * Normalizes the command and arguments to work cross-platform. + * On Windows, this basically handles things like shebangs, calling + * `node_modules/.bin` commands, and escaping meta characters. + * On other platforms, it just returns the command and arguments as-is. + */ +export function normalizeSpawnCommand( + command: string, + args: readonly string[] = [], + options: SpawnOptions = {} +): NormalizedSpawnCommand { + // Early return if use `shell` option or not on Windows. + if (options.shell === true || !isWindows) { + return {command, args, options}; + } + + // Detect & add support for shebangs + let file = resolveCommand(command, options); + let shebang: string | null = null; + + if (file !== null) { + // Read the first 150 bytes from the file + const size = 150; + const buffer = Buffer.alloc(size); + + let fd: number | null = null; + try { + fd = openSync(file, 'r'); + readSync(fd, buffer, 0, size, 0); + } catch { + // do nothing, we'll just assume it's not a shebang + } finally { + if (fd !== null) { + closeSync(fd); + } + } + + const match = buffer.toString().match(shebangRegExp); + + if (match !== null) { + const line = match[1].trim(); + const separatorIndex = line.indexOf(' '); + const path = separatorIndex !== -1 ? line.slice(0, separatorIndex) : line; + const argument = + separatorIndex !== -1 ? line.slice(separatorIndex + 1) : ''; + const binary = basename(path); + + shebang = binary === 'env' ? argument || null : binary; + } + } + + if (shebang !== null && file !== null) { + args = [file, ...args]; + command = shebang; + + file = resolveCommand(command, options); + } + + // We don't need a shell if the command filename is resolved and an executable + if (file === null || !isWindowsExecutableRegExp.test(file)) { + // Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/` + // The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument + // Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called, + // we need to double escape them + const needsDoubleEscapeMetaChars = + file !== null && isNodeModulesCmdRegExp.test(file); + + // Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar) + // This is necessary otherwise it will always fail with ENOENT in those cases + command = normalizePath(command); + + // Escape command & arguments + command = command.replace(metaCharsRegExp, '^$1'); + args = args.map((arg) => { + // Algorithm below is based on https://qntm.org/cmd + // It's slightly altered to disable JS backtracking to avoid hanging on specially crafted input + // Please see https://github.com/moxystudio/node-cross-spawn/pull/160 for more information + + // Sequence of backslashes followed by a double quote: + // double up all the backslashes and escape the double quote + arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"'); + + // Sequence of backslashes followed by the end of the string + // (which will become a double quote later): + // double up all the backslashes + arg = arg.replace(/(?=(\\+?)?)\1$/, '$1$1'); + + // All other backslashes occur literally + + // Quote the whole thing: + arg = `"${arg}"`; + + // Escape meta chars + arg = arg.replace(metaCharsRegExp, '^$1'); + + // Double escape meta chars if necessary + if (needsDoubleEscapeMetaChars) { + arg = arg.replace(metaCharsRegExp, '^$1'); + } + + return arg; + }); + + args = ['/d', '/s', '/c', `"${[command, ...args].join(' ')}"`]; + command = options.env?.comspec ?? 'cmd.exe'; + // Tell node's spawn that the arguments are already escaped + options = {...options, windowsVerbatimArguments: true}; + } + + return {command, args, options}; +} + +/** + * Resolves the command to an absolute path if possible. + * Handles things like traversing PATH and adding extensions from PATHEXT + */ +function resolveCommand(command: string, options: SpawnOptions): string | null { + const cwd = (options.cwd ?? getCwd()).toString(); + const env = options.env ?? process.env; + const PATH = getPathFromEnv(env).value; + + const pathEnv = + command.includes('/') || command.includes('\\') + ? [''] + : [cwd, ...PATH.split(pathDelimiter)]; + const pathExt = env.PATHEXT + ? env.PATHEXT.split(pathDelimiter) + : defaultPathExt; + + if (command.includes('.') && pathExt[0] !== '') { + pathExt.unshift(''); + } + + for (const path of pathEnv) { + const unquoted = + path.startsWith('"') && path.endsWith('"') && path.length > 1 + ? path.slice(1, -1) + : path; + const dest = resolvePath(cwd, unquoted, command); + + for (const ext of pathExt) { + const destWithExt = dest + ext; + + try { + if (statSync(destWithExt).isFile()) { + return destWithExt; + } + } catch { + // do nothing, it didn't exist + } + } + } + + return null; +} diff --git a/src/test/normalize_test.ts b/src/test/normalize_test.ts new file mode 100644 index 0000000..93b4c3a --- /dev/null +++ b/src/test/normalize_test.ts @@ -0,0 +1,53 @@ +import {normalizeSpawnCommand} from '../normalize.js'; +import {describe, test, expect} from 'vitest'; +import os from 'node:os'; + +const isWindows = os.platform() === 'win32'; +const baseWindowsOptions = { + env: process.env +}; + +describe('normalizeSpawnCommand', () => { + test('return from arguments if `shell` option is `true`', () => { + expect(normalizeSpawnCommand('node', ['-v'], {shell: true})).toEqual({ + command: 'node', + args: ['-v'], + options: {shell: true} + }); + }); + + describe.runIf(isWindows)('windows only', () => { + test('just return the same input if resolved', () => { + const normalized = normalizeSpawnCommand( + 'node', + ['-v'], + baseWindowsOptions + ); + + expect(normalized.command).toBe('node'); + expect(normalized.args).toEqual(['-v']); + }); + + test('use shell if command are not resolved/available', () => { + const normalized = normalizeSpawnCommand( + 'notexist', + ['hi'], + baseWindowsOptions + ); + + expect(normalized.command.endsWith('cmd.exe')).ok; + expect(normalized.args).toEqual(['/d', '/s', '/c', '"notexist ^"hi^""']); + expect(normalized.options.windowsVerbatimArguments).toBe(true); + }); + }); + + describe.runIf(!isWindows)('unix only', () => { + test('return from arguments', () => { + expect(normalizeSpawnCommand('node', ['-v'])).toEqual({ + command: 'node', + args: ['-v'], + options: {} + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index af1dc68..f906ede 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "types": ["node"], "declaration": true, "outDir": "./lib", + "rootDir": "./src", "importHelpers": false, "isolatedModules": true, "forceConsistentCasingInFileNames": true,