333 lines
8.1 KiB
TypeScript
333 lines
8.1 KiB
TypeScript
// This file represents useful wrappers over node:child_process
|
|
// These wrappers ease error handling and cross-platform compatibility.
|
|
// By using cross-spawn, Windows gets .cmd/.bat compatibility without falling
|
|
// back to a generic shell command string.
|
|
|
|
import { spawn } from 'cross-spawn'
|
|
import path from 'node:path'
|
|
import { getCwd } from '../utils/cwd.js'
|
|
import { logError } from './log.js'
|
|
|
|
export { execSyncWithDefaults_DEPRECATED } from './execFileNoThrowPortable.js'
|
|
|
|
const MS_IN_SECOND = 1000
|
|
const SECONDS_IN_MINUTE = 60
|
|
const DEFAULT_MAX_BUFFER = 1_000_000
|
|
|
|
type ExecFileOptions = {
|
|
abortSignal?: AbortSignal
|
|
timeout?: number
|
|
preserveOutputOnError?: boolean
|
|
// Setting useCwd=false avoids circular dependencies during initialization
|
|
// getCwd() -> PersistentShell -> logEvent() -> execFileNoThrow
|
|
useCwd?: boolean
|
|
env?: NodeJS.ProcessEnv
|
|
stdin?: 'ignore' | 'inherit' | 'pipe'
|
|
input?: string
|
|
}
|
|
|
|
type ExecFileWithCwdOptions = {
|
|
abortSignal?: AbortSignal
|
|
timeout?: number
|
|
preserveOutputOnError?: boolean
|
|
maxBuffer?: number
|
|
cwd?: string
|
|
env?: NodeJS.ProcessEnv
|
|
stdin?: 'ignore' | 'inherit' | 'pipe'
|
|
input?: string
|
|
}
|
|
|
|
type ProcessResultWithError = {
|
|
signal?: string
|
|
}
|
|
|
|
const CONTROL_CHAR_PATTERN = /[\0\r\n]/
|
|
const SAFE_BARE_EXECUTABLE_PATTERN = /^[A-Za-z0-9_.-]+$/
|
|
|
|
function hasPathSyntax(value: string): boolean {
|
|
return (
|
|
value.includes(path.sep) ||
|
|
value.includes('/') ||
|
|
path.isAbsolute(value)
|
|
)
|
|
}
|
|
|
|
function validateExecutable(file: string): string | null {
|
|
const normalized = file.trim()
|
|
if (!normalized) {
|
|
return 'Unsafe executable: empty command'
|
|
}
|
|
if (CONTROL_CHAR_PATTERN.test(normalized)) {
|
|
return 'Unsafe executable: control characters are not allowed'
|
|
}
|
|
if (
|
|
!hasPathSyntax(normalized) &&
|
|
!SAFE_BARE_EXECUTABLE_PATTERN.test(normalized)
|
|
) {
|
|
return 'Unsafe executable: bare command names may only contain letters, numbers, ".", "_" and "-"'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function validateArgs(args: string[]): string | null {
|
|
for (const arg of args) {
|
|
if (CONTROL_CHAR_PATTERN.test(arg)) {
|
|
return 'Unsafe argument: control characters are not allowed'
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function validateWorkingDirectory(cwd: string | undefined): string | null {
|
|
if (!cwd) {
|
|
return null
|
|
}
|
|
if (CONTROL_CHAR_PATTERN.test(cwd)) {
|
|
return 'Unsafe working directory: control characters are not allowed'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function sanitizeEnvironment(
|
|
env: NodeJS.ProcessEnv | undefined,
|
|
): { value?: NodeJS.ProcessEnv; error?: string } {
|
|
if (!env) {
|
|
return {}
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(env)) {
|
|
if (CONTROL_CHAR_PATTERN.test(key)) {
|
|
return {
|
|
error: 'Unsafe environment: control characters are not allowed in keys',
|
|
}
|
|
}
|
|
if (typeof value === 'string' && CONTROL_CHAR_PATTERN.test(value)) {
|
|
return {
|
|
error:
|
|
'Unsafe environment: control characters are not allowed in values',
|
|
}
|
|
}
|
|
}
|
|
|
|
return { value: env }
|
|
}
|
|
|
|
/**
|
|
* Extracts a human-readable error message from a process result.
|
|
*
|
|
* Priority order:
|
|
* 1. signal - the signal that killed the process (e.g., "SIGTERM")
|
|
* 2. errorCode - fallback to just the numeric exit code
|
|
*/
|
|
function getErrorMessage(
|
|
result: ProcessResultWithError,
|
|
errorCode: number,
|
|
): string {
|
|
if (typeof result.signal === 'string') {
|
|
return result.signal
|
|
}
|
|
return String(errorCode)
|
|
}
|
|
|
|
export function execFileNoThrow(
|
|
file: string,
|
|
args: string[],
|
|
options: ExecFileOptions = {
|
|
timeout: 10 * SECONDS_IN_MINUTE * MS_IN_SECOND,
|
|
preserveOutputOnError: true,
|
|
useCwd: true,
|
|
},
|
|
): Promise<{ stdout: string; stderr: string; code: number; error?: string }> {
|
|
return execFileNoThrowWithCwd(file, args, {
|
|
abortSignal: options.abortSignal,
|
|
timeout: options.timeout,
|
|
preserveOutputOnError: options.preserveOutputOnError,
|
|
cwd: options.useCwd ? getCwd() : undefined,
|
|
env: options.env,
|
|
stdin: options.stdin,
|
|
input: options.input,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* execFile, but always resolves (never throws)
|
|
*/
|
|
export function execFileNoThrowWithCwd(
|
|
file: string,
|
|
args: string[],
|
|
{
|
|
abortSignal,
|
|
timeout: finalTimeout = 10 * SECONDS_IN_MINUTE * MS_IN_SECOND,
|
|
preserveOutputOnError: finalPreserveOutput = true,
|
|
cwd: finalCwd,
|
|
env: finalEnv,
|
|
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
stdin: finalStdin,
|
|
input: finalInput,
|
|
}: ExecFileWithCwdOptions = {
|
|
timeout: 10 * SECONDS_IN_MINUTE * MS_IN_SECOND,
|
|
preserveOutputOnError: true,
|
|
maxBuffer: DEFAULT_MAX_BUFFER,
|
|
},
|
|
): Promise<{ stdout: string; stderr: string; code: number; error?: string }> {
|
|
const executableError = validateExecutable(file)
|
|
if (executableError) {
|
|
return Promise.resolve({
|
|
stdout: '',
|
|
stderr: '',
|
|
code: 1,
|
|
error: executableError,
|
|
})
|
|
}
|
|
|
|
const argsError = validateArgs(args)
|
|
if (argsError) {
|
|
return Promise.resolve({
|
|
stdout: '',
|
|
stderr: '',
|
|
code: 1,
|
|
error: argsError,
|
|
})
|
|
}
|
|
|
|
const cwdError = validateWorkingDirectory(finalCwd)
|
|
if (cwdError) {
|
|
return Promise.resolve({
|
|
stdout: '',
|
|
stderr: '',
|
|
code: 1,
|
|
error: cwdError,
|
|
})
|
|
}
|
|
|
|
const sanitizedEnv = sanitizeEnvironment(finalEnv)
|
|
if (sanitizedEnv.error) {
|
|
return Promise.resolve({
|
|
stdout: '',
|
|
stderr: '',
|
|
code: 1,
|
|
error: sanitizedEnv.error,
|
|
})
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
const stdinMode = finalInput !== undefined ? 'pipe' : finalStdin ?? 'pipe'
|
|
const child = spawn(file, args, {
|
|
cwd: finalCwd,
|
|
env: sanitizedEnv.value,
|
|
shell: false,
|
|
signal: abortSignal,
|
|
stdio: [stdinMode, 'pipe', 'pipe'],
|
|
})
|
|
|
|
let settled = false
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let combinedBufferSize = 0
|
|
let signal: string | undefined
|
|
let timedOut = false
|
|
|
|
const finish = (result: {
|
|
stdout: string
|
|
stderr: string
|
|
code: number
|
|
error?: string
|
|
}) => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
void resolve(result)
|
|
}
|
|
|
|
const appendOutput = (
|
|
chunk: string | Buffer,
|
|
target: 'stdout' | 'stderr',
|
|
) => {
|
|
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
|
combinedBufferSize += Buffer.byteLength(text, 'utf8')
|
|
if (combinedBufferSize > maxBuffer) {
|
|
child.kill()
|
|
finish({
|
|
stdout: finalPreserveOutput ? stdout : '',
|
|
stderr: finalPreserveOutput ? stderr : '',
|
|
code: 1,
|
|
error: 'maxBuffer exceeded',
|
|
})
|
|
return
|
|
}
|
|
|
|
if (target === 'stdout') {
|
|
stdout += text
|
|
} else {
|
|
stderr += text
|
|
}
|
|
}
|
|
|
|
child.stdout?.on('data', chunk => appendOutput(chunk, 'stdout'))
|
|
child.stderr?.on('data', chunk => appendOutput(chunk, 'stderr'))
|
|
|
|
child.once('spawn', () => {
|
|
if (stdinMode === 'pipe' && child.stdin) {
|
|
if (finalInput !== undefined) {
|
|
child.stdin.end(finalInput)
|
|
} else {
|
|
child.stdin.end()
|
|
}
|
|
}
|
|
})
|
|
|
|
child.once('error', error => {
|
|
logError(error)
|
|
finish({ stdout: '', stderr: '', code: 1, error: error.message })
|
|
})
|
|
|
|
const timeoutId =
|
|
finalTimeout > 0
|
|
? setTimeout(() => {
|
|
timedOut = true
|
|
child.kill()
|
|
}, finalTimeout)
|
|
: undefined
|
|
|
|
child.once('close', (code, closeSignal) => {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
|
|
signal = closeSignal ?? undefined
|
|
const errorCode = code ?? 1
|
|
|
|
if (timedOut) {
|
|
finish({
|
|
stdout: finalPreserveOutput ? stdout : '',
|
|
stderr: finalPreserveOutput ? stderr : '',
|
|
code: errorCode,
|
|
error: `Command timed out after ${finalTimeout}ms`,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (errorCode !== 0) {
|
|
if (finalPreserveOutput) {
|
|
finish({
|
|
stdout,
|
|
stderr,
|
|
code: errorCode,
|
|
error: getErrorMessage({ signal }, errorCode),
|
|
})
|
|
} else {
|
|
finish({ stdout: '', stderr: '', code: errorCode })
|
|
}
|
|
return
|
|
}
|
|
|
|
finish({
|
|
stdout,
|
|
stderr,
|
|
code: 0,
|
|
})
|
|
})
|
|
})
|
|
}
|