type CommandWrapper = "env" | "sudo"; const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record> = { env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), }; const COMMAND_WRAPPER_FLAGS: Record> = { env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug", "-v"]), sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]), }; function tokenizeShellCommand(command: string): string[] | null { const input = command.trim(); const tokens: string[] = []; let current = ""; let quote: '"' | "'" | null = null; let escaping = false; let substitutionDepth = 0; let tokenStarted = false; for (let index = 0; index < input.length; index += 1) { const character = input[index]!; if (escaping) { current += character; escaping = false; tokenStarted = true; continue; } if (character === "\\" && quote !== "'") { const nextCharacter = input[index + 1]; const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current); if ( (quote === '"' || isWindowsDrivePath) && nextCharacter !== undefined && nextCharacter !== '"' && nextCharacter !== "\\" && nextCharacter !== "$" && nextCharacter !== "`" && nextCharacter !== "\n" ) { current += character; tokenStarted = true; continue; } escaping = true; tokenStarted = true; continue; } if (quote !== null) { if (character === quote) { quote = null; } else { current += character; } tokenStarted = true; continue; } if (character === "$" && input[index + 1] === "(") { current += "$("; substitutionDepth += 1; tokenStarted = true; index += 1; continue; } if (character === ")" && substitutionDepth > 0) { current += character; substitutionDepth -= 1; tokenStarted = true; continue; } if (character === '"' || character === "'") { quote = character; tokenStarted = true; continue; } if (/\s/u.test(character)) { if (substitutionDepth > 0) { current += character; tokenStarted = true; continue; } if (tokenStarted) { tokens.push(current); current = ""; tokenStarted = false; } continue; } current += character; tokenStarted = true; } if (quote !== null || escaping || substitutionDepth > 0) return null; if (tokenStarted) tokens.push(current); return tokens; } export function commandProgramName(command: string, depth = 0): string | null { if (depth >= 8) return null; const tokens = tokenizeShellCommand(command); if (tokens === null) return null; let index = 0; let wrapper: CommandWrapper | null = null; while (index < tokens.length) { const token = tokens[index]; if (!token) return null; if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { index += 1; continue; } const tokenProgram = token.split(/[\\/]/).at(-1); if (tokenProgram === "env" || tokenProgram === "sudo") { wrapper = tokenProgram; index += 1; continue; } if (wrapper !== null && token === "--") { wrapper = null; index += 1; continue; } if (wrapper !== null && token.startsWith("-")) { if (wrapper === "env" && (token === "-S" || token === "--split-string")) { const splitCommand = tokens[index + 1]; return splitCommand ? commandProgramName(splitCommand, depth + 1) : null; } if (wrapper === "env" && token.startsWith("--split-string=")) { return commandProgramName(token.slice("--split-string=".length), depth + 1); } if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { if (tokens[index + 1] === undefined) return null; index += 2; continue; } if (COMMAND_WRAPPER_FLAGS[wrapper].has(token)) { index += 1; continue; } const equalsIndex = token.indexOf("="); if (token.startsWith("--") && equalsIndex > 2) { if (!COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token.slice(0, equalsIndex))) { return null; } index += 1; continue; } if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) { let consumesNextToken = false; for (const [optionIndex, option] of token.slice(1).split("").entries()) { const shortOption = `-${option}`; if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) { consumesNextToken = optionIndex === token.length - 2; break; } if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null; } if (consumesNextToken && tokens[index + 1] === undefined) return null; index += consumesNextToken ? 2 : 1; continue; } return null; } return token.split(/[\\/]/).at(-1) || null; } return null; }