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,