import { ChildProcess, spawn } from "child_process" import { randomUUID } from "crypto" import { mkdir, rm } from "fs/promises" import path from "path" import { Readable as ReadableStream } from "stream" import { Logger } from "./logger" import { Context, ToString } from "./types" import { displayCommand, redact } from "./utils" export const ensureDir = async (dir: string) => { // mkdir doesn't throw an error if the directory already exists await mkdir(dir, { recursive: true }) return dir } export const initDatabaseDir = async (dir: string) => { dir = await ensureDir(dir) await rm(path.join(dir, "LOCK"), { // ignore error if the file does not exist force: true, }) return dir } export class CommandRunner { private logger: Logger constructor( ctx: Context, private configuration: { itemsToRedact: string[] shouldTrackProgress?: boolean cwd?: string onChild?: (child: ChildProcess) => void }, ) { this.logger = ctx.logger.child({ commandId: randomUUID() }) } async run( execPath: string, args: string[], { allowedErrorCodes, testAllowedErrorMessage, shouldCaptureAllStreams, stdinInput, }: { allowedErrorCodes?: number[] testAllowedErrorMessage?: (stderr: string) => boolean shouldCaptureAllStreams?: boolean stdinInput?: string } = {}, ) { const { logger } = this return new Promise<string | Error>((resolve, reject) => { const { cwd, itemsToRedact, onChild, shouldTrackProgress } = this.configuration const commandDisplayed = displayCommand({ execPath, args, itemsToRedact }) logger.info(`Executing command ${commandDisplayed}`) const child = spawn(execPath, args, { cwd, stdio: "pipe" }) if (onChild) { onChild(child) } if (stdinInput) { const stdinStream = new ReadableStream() stdinStream.push(stdinInput) stdinStream.push(null) stdinStream.pipe(child.stdin) } const commandOutputBuffer: ["stdout" | "stderr", string][] = [] const getStreamHandler = (channel: "stdout" | "stderr") => { return (data: ToString) => { const str = itemsToRedact === undefined ? data.toString() : redact(data.toString(), itemsToRedact) const strTrim = str.trim() if (shouldTrackProgress && strTrim) { logger.info(strTrim, channel) } commandOutputBuffer.push([channel, str]) } } child.stdout.on("data", getStreamHandler("stdout")) child.stderr.on("data", getStreamHandler("stderr")) child.on("close", (exitCode, signal) => { logger.info( `Process finished with exit code ${exitCode ?? "??"}${ signal ? `and signal ${signal}` : "" }`, ) if (signal) { return resolve( new Error(`Process got terminated by signal ${signal}`), ) } if (exitCode) { const rawStderr = commandOutputBuffer .reduce((acc, [stream, value]) => { if (stream === "stderr") { return `${acc}${value}` } else { return acc } }, "") .trim() const stderr = itemsToRedact === undefined ? rawStderr : redact(rawStderr, itemsToRedact) if ( !allowedErrorCodes?.includes(exitCode) && (testAllowedErrorMessage === undefined || !testAllowedErrorMessage(stderr)) ) { return reject(new Error(stderr)) } } const outputBuf = shouldCaptureAllStreams ? commandOutputBuffer.reduce((acc, [_, value]) => { return `${acc}${value}` }, "") : commandOutputBuffer.reduce((acc, [stream, value]) => { if (stream === "stdout") { return `${acc}${value}` } else { return acc } }, "") const rawOutput = outputBuf.trim() const output = itemsToRedact === undefined ? rawOutput : redact(rawOutput, itemsToRedact) resolve(output) }) }) } } export const validateSingleShellCommand = async ( ctx: Context, command: string, ) => { const { logger } = ctx const cmdRunner = new CommandRunner(ctx, { itemsToRedact: [] }) const commandAstText = await cmdRunner.run("shfmt", ["--tojson"], { stdinInput: command, }) if (commandAstText instanceof Error) { return new Error(`Command AST could not be parsed for "${command}"`) } const commandAst = JSON.parse(commandAstText) as { Stmts: { Cmd: { Type?: string } }[] } logger.info(commandAst.Stmts[0].Cmd, `Parsed AST for "${command}"`) if ( commandAst.Stmts.length !== 1 || commandAst.Stmts[0].Cmd.Type !== "CallExpr" ) { return new Error( `Command "${command}" failed validation: the resulting command line should have a single command`, ) } return command }