From 17dbda3d4da505df93380d7fbd42cf0b5835e10b Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Wed, 23 Jul 2025 12:21:02 -0600 Subject: [PATCH] fix: update command parser to handle all edge cases from original implementation - Add support for all line ending types (\r\n, \r, \n) - Handle simple variable references (, ) to prevent shell-quote from splitting them - Handle special bash variables (0, , etc.) - Maintain compatibility with all existing tests - Fix linting warnings --- webview-ui/src/utils/command-parser.ts | 228 ++++++++++++++++--------- 1 file changed, 151 insertions(+), 77 deletions(-) diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index 1384fff3d3..a3fb6d92e2 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -27,97 +27,171 @@ export function parseCommandString(command: string): { } } - // Storage for replaced content - const redirections: string[] = [] - const subshells: string[] = [] - const quotes: string[] = [] - const arrayIndexing: string[] = [] + try { + // First split by newlines (including all types: \n, \r\n, \r) to handle multi-line commands + const lines = command.split(/\r\n|\r|\n/) + const allCommands: string[] = [] - // First handle PowerShell redirections by temporarily replacing them - let processedCommand = command.replace(/\d*>&\d*/g, (match) => { - redirections.push(match) - return `__REDIR_${redirections.length - 1}__` - }) + for (const line of lines) { + const trimmedLine = line.trim() + if (!trimmedLine) continue // Skip empty lines - // Handle array indexing expressions: ${array[...]} pattern and partial expressions - processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => { - arrayIndexing.push(match) - return `__ARRAY_${arrayIndexing.length - 1}__` - }) + // Storage for replaced content + const redirections: string[] = [] + const subshells: string[] = [] + const quotes: string[] = [] + const arrayIndexing: string[] = [] + const arithmeticExpressions: string[] = [] + const variables: string[] = [] - // Then handle subshell commands - store them for security analysis - const hasSubshells = command.includes("$(") || command.includes("`") + // First handle PowerShell redirections by temporarily replacing them + let processedCommand = trimmedLine.replace(/\d*>&\d*/g, (match) => { + redirections.push(match) + return `__REDIR_${redirections.length - 1}__` + }) - processedCommand = processedCommand - .replace(/\$\((.*?)\)/g, (_, inner) => { - const trimmedInner = inner.trim() - subshells.push(trimmedInner) - return `__SUBSH_${subshells.length - 1}__` - }) - .replace(/`(.*?)`/g, (_, inner) => { - const trimmedInner = inner.trim() - subshells.push(trimmedInner) - return `__SUBSH_${subshells.length - 1}__` - }) + // Handle arithmetic expressions: $((...)) pattern + // Match the entire arithmetic expression including nested parentheses + processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => { + arithmeticExpressions.push(match) + return `__ARITH_${arithmeticExpressions.length - 1}__` + }) - // Then handle quoted strings - processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { - quotes.push(match) - return `__QUOTE_${quotes.length - 1}__` - }) + // Handle array indexing expressions: ${array[...]} pattern and partial expressions + processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => { + arrayIndexing.push(match) + return `__ARRAY_${arrayIndexing.length - 1}__` + }) - const tokens = parse(processedCommand) as ShellToken[] - const commands: string[] = [] - let currentCommand: string[] = [] + // Handle simple variable references: $varname pattern + // This prevents shell-quote from splitting $count into separate tokens + processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => { + variables.push(match) + return `__VAR_${variables.length - 1}__` + }) - for (const token of tokens) { - if (typeof token === "object" && "op" in token) { - // Chain operator - split command - if (["&&", "||", ";", "|"].includes(token.op)) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] + // Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9 + processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => { + variables.push(match) + return `__VAR_${variables.length - 1}__` + }) + + // Then handle subshell commands - store them for security analysis + const _hasSubshells = trimmedLine.includes("$(") || trimmedLine.includes("`") + + processedCommand = processedCommand + .replace(/\$\(((?!\().*?)\)/g, (_, inner) => { + const trimmedInner = inner.trim() + subshells.push(trimmedInner) + return `__SUBSH_${subshells.length - 1}__` + }) + .replace(/`(.*?)`/g, (_, inner) => { + const trimmedInner = inner.trim() + subshells.push(trimmedInner) + return `__SUBSH_${subshells.length - 1}__` + }) + + // Then handle quoted strings + processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { + quotes.push(match) + return `__QUOTE_${quotes.length - 1}__` + }) + + const tokens = parse(processedCommand) as ShellToken[] + const commands: string[] = [] + let currentCommand: string[] = [] + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + + if (typeof token === "object" && "op" in token) { + // Chain operator - split command + if (["&&", "||", ";", "|"].includes(token.op)) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] + } + } else { + // Other operators (>, &) are part of the command + currentCommand.push(token.op) + } + } else if (typeof token === "string") { + // Check if it's a subshell placeholder + const subshellMatch = token.match(/__SUBSH_(\d+)__/) + if (subshellMatch) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] + } + commands.push(subshells[parseInt(subshellMatch[1])]) + } else { + currentCommand.push(token) + } } - } else { - // Other operators (>, &) are part of the command - currentCommand.push(token.op) } - } else if (typeof token === "string") { - // Check if it's a subshell placeholder - const subshellMatch = token.match(/__SUBSH_(\d+)__/) - if (subshellMatch) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] - } - commands.push(subshells[parseInt(subshellMatch[1])]) - } else { - currentCommand.push(token) + + // Add any remaining command + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + } + + // Restore quotes, redirections, arithmetic expressions, variables, and array indexing + const restoredCommands = commands.map((cmd) => { + let result = cmd + // Restore quotes + result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) + // Restore redirections + result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) + // Restore arithmetic expressions + result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) + // Restore variables + result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) + // Restore array indexing expressions + result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) + return result + }) + + allCommands.push(...restoredCommands) + } + + // Check if any line has subshells + const hasSubshells = command.includes("$(") || command.includes("`") + const subshellCommands: string[] = [] + + // Extract subshell commands for security analysis + let match: RegExpExecArray | null + const subshellRegex1 = /\$\(((?!\().*?)\)/g + const subshellRegex2 = /`(.*?)`/g + + while ((match = subshellRegex1.exec(command)) !== null) { + if (match[1]) { + subshellCommands.push(match[1].trim()) } } - } - // Add any remaining command - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - } + while ((match = subshellRegex2.exec(command)) !== null) { + if (match[1]) { + subshellCommands.push(match[1].trim()) + } + } - // Restore quotes, redirections, and array indexing - const restoredCommands = commands.map((cmd) => { - let result = cmd - // Restore quotes - result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) - // Restore redirections - result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) - // Restore array indexing expressions - result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) - return result - }) + return { + subCommands: allCommands, + hasSubshells, + subshellCommands, + } + } catch (_error) { + // If shell-quote fails, fall back to simple splitting + const fallbackCommands = command + .split(/\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) - return { - subCommands: restoredCommands, - hasSubshells, - subshellCommands: subshells, + return { + subCommands: fallbackCommands.length > 0 ? fallbackCommands : [command], + hasSubshells: command.includes("$(") || command.includes("`"), + subshellCommands: [], + } } }