From febd4bf25db09cc7e9c3186cb4503344156f5423 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:04:06 +0100 Subject: [PATCH] feat: fork cross-spawn to fix esbuild chaos esbuild isn't great at bundling for node. it currently produces invalid code by leaving require calls in the esm output, which is far from great. To resolve this, this moves cross-spawn into the repo and converts it to ESM while dropping any logic we weren't making use of. --- LICENSE | 1 + package-lock.json | 74 +++++++++++++++++----- package.json | 7 ++- src/cross-spawn.ts | 105 +++++++++++++++++++++++++++++++ src/env.ts | 2 +- src/escape-command.ts | 38 +++++++++++ src/resolve-command.ts | 63 +++++++++++++++++++ src/shared-types.ts | 7 +++ src/test/escape-command_test.ts | 67 ++++++++++++++++++++ src/test/main_test.ts | 2 +- src/test/resolve-command_test.ts | 31 +++++++++ tsconfig.json | 2 +- 12 files changed, 380 insertions(+), 19 deletions(-) create mode 100644 src/cross-spawn.ts create mode 100644 src/escape-command.ts create mode 100644 src/resolve-command.ts create mode 100644 src/shared-types.ts create mode 100644 src/test/escape-command_test.ts create mode 100644 src/test/resolve-command_test.ts diff --git a/LICENSE b/LICENSE index 558eb6a..6f27d6c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2024 Tinylibs +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 diff --git a/package-lock.json b/package-lock.json index 261106d..a9c74dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,27 @@ { "name": "tinyexec", - "version": "0.0.1", + "version": "0.0.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tinyexec", - "version": "0.0.1", + "version": "0.0.0-dev", "license": "MIT", "devDependencies": { "@eslint/js": "^9.0.0", "@types/cross-spawn": "^6.0.6", "@types/node": "^20.12.7", + "@types/shebang-command": "^1.2.2", + "@types/which": "^3.0.4", "c8": "^9.1.0", - "cross-spawn": "^7.0.3", "eslint-config-google": "^0.14.0", "prettier": "^3.2.5", + "shebang-command": "^2.0.0", "tsup": "^8.1.0", "typescript": "^5.4.5", - "typescript-eslint": "^7.7.0" + "typescript-eslint": "^7.7.0", + "which": "^4.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -937,6 +940,20 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/shebang-command": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/shebang-command/-/shebang-command-1.2.2.tgz", + "integrity": "sha512-qr5yUISYlzwq91s5PIzvs8bR/NQOwaN586kg5eLc1N46uvOTMtMN9J9z9NySmVjy2dlHPr5UmyCBZLRwgfYgpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/which": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.4.tgz", + "integrity": "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.0.tgz", @@ -1518,6 +1535,29 @@ "node": ">= 8" } }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -2248,10 +2288,14 @@ } }, "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -2963,6 +3007,7 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -3500,18 +3545,19 @@ } }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", "dev": true, + "license": "ISC", "dependencies": { - "isexe": "^2.0.0" + "isexe": "^3.1.1" }, "bin": { - "node-which": "bin/node-which" + "node-which": "bin/which.js" }, "engines": { - "node": ">= 8" + "node": "^16.13.0 || >=18.0.0" } }, "node_modules/wrap-ansi": { diff --git a/package.json b/package.json index 372abb0..6e0ea93 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,16 @@ "@eslint/js": "^9.0.0", "@types/cross-spawn": "^6.0.6", "@types/node": "^20.12.7", + "@types/shebang-command": "^1.2.2", + "@types/which": "^3.0.4", "c8": "^9.1.0", - "cross-spawn": "^7.0.3", "eslint-config-google": "^0.14.0", "prettier": "^3.2.5", "tsup": "^8.1.0", "typescript": "^5.4.5", - "typescript-eslint": "^7.7.0" + "typescript-eslint": "^7.7.0", + "shebang-command": "^2.0.0", + "which": "^4.0.0" }, "exports": { ".": { diff --git a/src/cross-spawn.ts b/src/cross-spawn.ts new file mode 100644 index 0000000..52259ac --- /dev/null +++ b/src/cross-spawn.ts @@ -0,0 +1,105 @@ +import * as path from 'node:path'; +import {env} from 'node:process'; +import {type SpawnOptions} from 'node:child_process'; +import {openSync, readSync, closeSync} from 'node:fs'; +import {resolveCommand} from './resolve-command.js'; +import {escapeArgument, escapeCommand} from './escape-command.js'; +import {type ParsedShellOptions} from './shared-types.js'; +import shebangCommand from 'shebang-command'; + +const isWin = process.platform === 'win32'; +const isExecutableRegExp = /\.(?:com|exe)$/i; +const isCmdShimRegExp = /node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i; + +function readShebang(command: string): string | null { + // Read the first 150 bytes from the file + const size = 150; + const buffer = Buffer.alloc(size); + + let fd; + + try { + fd = openSync(command, 'r'); + readSync(fd, buffer, 0, size, 0); + closeSync(fd); + } catch (e) { + /* Empty */ + } + + // Attempt to extract shebang (null is returned if not a shebang) + return shebangCommand(buffer.toString()); +} + +module.exports = readShebang; + +function detectShebang(parsed: ParsedShellOptions): string | undefined { + const file = resolveCommand(parsed); + + const shebang = file && readShebang(file); + + if (shebang) { + parsed.args.unshift(file); + parsed.command = shebang; + + return resolveCommand(parsed); + } + + return file; +} + +function parseNonShell(parsed: ParsedShellOptions): ParsedShellOptions { + if (!isWin) { + return parsed; + } + + // Detect & add support for shebangs + const commandFile = detectShebang(parsed) ?? parsed.command; + + // We don't need a shell if the command filename is an executable + const needsShell = !isExecutableRegExp.test(commandFile); + + // If a shell is required, use cmd.exe and take care of escaping everything correctly + if (needsShell) { + // 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 = isCmdShimRegExp.test(commandFile); + + // 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 + parsed.command = path.normalize(parsed.command); + + // Escape command & arguments + parsed.command = escapeCommand(parsed.command); + parsed.args = parsed.args.map((arg) => + escapeArgument(arg, needsDoubleEscapeMetaChars) + ); + + const shellCommand = [parsed.command].concat(parsed.args).join(' '); + + parsed.args = ['/d', '/s', '/c', `"${shellCommand}"`]; + parsed.command = env.comspec || 'cmd.exe'; + parsed.options.windowsVerbatimArguments = true; // Tell node's spawn that the arguments are already escaped + } + + return parsed; +} + +export function parse(command: string, args: string[], options: SpawnOptions) { + if (options.shell) { + return {command, args, options}; + } + + const argsCopy = args.slice(0); + const optionsCopy = {...options}; + + // Build our parsed object + const parsed: ParsedShellOptions = { + command, + args: argsCopy, + options: optionsCopy + }; + + return parseNonShell(parsed); +} diff --git a/src/env.ts b/src/env.ts index 461b7a0..b267be2 100644 --- a/src/env.ts +++ b/src/env.ts @@ -14,7 +14,7 @@ export interface EnvPathInfo { const isPathLikePattern = /^path$/i; const defaultEnvPathInfo = {key: 'PATH', value: ''}; -function getPathFromEnv(env: EnvLike): EnvPathInfo { +export function getPathFromEnv(env: EnvLike): EnvPathInfo { for (const key in env) { if ( !Object.prototype.hasOwnProperty.call(env, key) || diff --git a/src/escape-command.ts b/src/escape-command.ts new file mode 100644 index 0000000..6210927 --- /dev/null +++ b/src/escape-command.ts @@ -0,0 +1,38 @@ +// See http://www.robvanderwoude.com/escapechars.php +const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; + +export function escapeCommand(arg: string) { + // Escape meta chars + return arg.replace(metaCharsRegExp, '^$1'); +} + +export function escapeArgument(arg: string, doubleEscapeMetaChars: boolean) { + // Convert to string + arg = `${arg}`; + + // Algorithm below is based on https://qntm.org/cmd + + // Sequence of backslashes followed by a double quote: + // double up all the backslashes and escape the double quote + arg = arg.replace(/(\\*)"/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'); + + // 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 (doubleEscapeMetaChars) { + arg = arg.replace(metaCharsRegExp, '^$1'); + } + + return arg; +} diff --git a/src/resolve-command.ts b/src/resolve-command.ts new file mode 100644 index 0000000..d6b73f2 --- /dev/null +++ b/src/resolve-command.ts @@ -0,0 +1,63 @@ +import * as path from 'node:path'; +import {chdir} from 'node:process'; +import which from 'which'; +import {getPathFromEnv} from './env.js'; +import {type ParsedShellOptions} from './shared-types.js'; + +function resolveCommandAttempt( + parsed: ParsedShellOptions, + withoutPathExt: boolean +): string | undefined { + const env = parsed.options.env || process.env; + const cwd = process.cwd(); + const hasCustomCwd = parsed.options.cwd != null; + // Worker threads do not have process.chdir() + const shouldSwitchCwd = + hasCustomCwd && + chdir !== undefined && + !(chdir as typeof chdir & {disabled?: boolean}).disabled; + + // If a custom `cwd` was specified, we need to change the process cwd + // because `which` will do stat calls but does not support a custom cwd + if (shouldSwitchCwd && typeof parsed.options.cwd === 'string') { + try { + chdir(parsed.options.cwd); + } catch (err) { + /* Empty */ + } + } + + let resolved; + + try { + resolved = which.sync(parsed.command, { + path: getPathFromEnv(env).value, + pathExt: withoutPathExt ? path.delimiter : undefined + }); + } catch (e) { + /* Empty */ + } finally { + if (shouldSwitchCwd) { + chdir(cwd); + } + } + + // If we successfully resolved, ensure that an absolute path is returned + // Note that when a custom `cwd` was used, we need to resolve to an absolute path based on it + if (resolved) { + resolved = path.resolve( + hasCustomCwd && typeof parsed.options.cwd === 'string' + ? parsed.options.cwd + : '', + resolved + ); + } + + return resolved; +} + +export function resolveCommand(parsed: ParsedShellOptions): string | undefined { + return ( + resolveCommandAttempt(parsed, false) || resolveCommandAttempt(parsed, true) + ); +} diff --git a/src/shared-types.ts b/src/shared-types.ts new file mode 100644 index 0000000..34aa81b --- /dev/null +++ b/src/shared-types.ts @@ -0,0 +1,7 @@ +import {type SpawnOptions} from 'node:child_process'; + +export interface ParsedShellOptions { + command: string; + args: string[]; + options: SpawnOptions; +} diff --git a/src/test/escape-command_test.ts b/src/test/escape-command_test.ts new file mode 100644 index 0000000..cb20cb2 --- /dev/null +++ b/src/test/escape-command_test.ts @@ -0,0 +1,67 @@ +import {escapeCommand, escapeArgument} from '../escape-command.js'; +import * as assert from 'node:assert/strict'; +import {test} from 'node:test'; + +const metas = [ + '(', + ')', + ']', + '[', + '%', + '!', + '^', + '"', + '`', + '<', + '>', + '&', + '|', + ';', + ',', + ' ', + '*', + '?' +]; +test('escapeCommand', async (t) => { + await t.test('escapes meta chars', () => { + for (const chr of metas) { + assert.equal(escapeCommand(`foo ${chr} bar`), `foo^ ^${chr}^ bar`); + } + }); + + await t.test('leaves non-meta chars as is', () => { + assert.equal(escapeCommand('foo'), 'foo'); + }); +}); + +test('escapeArgument', async (t) => { + await t.test('doubles escapes before quotes', () => { + assert.equal(escapeArgument('\\\\"', false), '^"\\\\\\\\\\^"^"'); + }); + + await t.test('double escapes backslashes before eof', () => { + assert.equal(escapeArgument('foo\\\\', false), '^"foo\\\\\\\\^"'); + }); + + await t.test('wraps the argument in quotes', () => { + assert.equal(escapeArgument('foo', false), '^"foo^"'); + }); + + await t.test('escapes meta chars', () => { + for (const chr of metas) { + assert.equal( + escapeArgument(`foo ${chr} bar`, false), + `^"foo^ ${chr === '"' ? '\\' : ''}^${chr}^ bar^"` + ); + } + }); + + await t.test('double escapes meta chars if specified', () => { + for (const chr of metas) { + assert.equal( + escapeArgument(`foo ${chr} bar`, true), + `^^^"foo^^^ ${chr === '"' ? '\\' : ''}^^^${chr}^^^ bar^^^"` + ); + } + }); +}); diff --git a/src/test/main_test.ts b/src/test/main_test.ts index 06ea12f..45eb386 100644 --- a/src/test/main_test.ts +++ b/src/test/main_test.ts @@ -117,7 +117,7 @@ if (isWindows) { if (!isWindows) { test('exec (unix-like)', async (t) => { await t.test('times out after defined timeout (ms)', async () => { - const proc = x('sleep', ['0.2s'], {timeout: 100}); + const proc = x('sleep', ['0.2'], {timeout: 100}); await assert.rejects(async () => { await proc; }); diff --git a/src/test/resolve-command_test.ts b/src/test/resolve-command_test.ts new file mode 100644 index 0000000..a87122c --- /dev/null +++ b/src/test/resolve-command_test.ts @@ -0,0 +1,31 @@ +import {resolveCommand} from '../resolve-command.js'; +import * as assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {resolve} from 'node:path'; +import {cwd as getCwd} from 'node:process'; + +test('resolveCommand', async (t) => { + await t.test('can resolve commands', () => { + const cwd = getCwd(); + const resolved = resolveCommand({ + command: 'node', + args: [], + options: {} + }); + + assert.ok(resolved); + assert.equal(cwd, getCwd()); + }); + + await t.test('can resolve commands from custom cwd', () => { + const cwd = getCwd(); + const resolved = resolveCommand({ + command: 'node', + args: [], + options: {cwd: resolve(cwd, './src')} + }); + + assert.ok(resolved); + assert.equal(cwd, getCwd()); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index af1dc68..2e81ff9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "es2022", "module": "node16", "moduleResolution": "node16", - "types": ["node"], + "types": ["node", "which", "shebang-command"], "declaration": true, "outDir": "./lib", "importHelpers": false,