diff --git a/webview-ui/src/utils/__tests__/command-validation.spec.ts b/webview-ui/src/utils/__tests__/command-validation.spec.ts index e9265616822..661d09c802a 100644 --- a/webview-ui/src/utils/__tests__/command-validation.spec.ts +++ b/webview-ui/src/utils/__tests__/command-validation.spec.ts @@ -276,6 +276,123 @@ done` parseCommand(problematicPart) }).not.toThrow("Bad substitution") }) + + it("should handle bash arithmetic expressions with $(())", () => { + // Test the exact script from the user's error + const bashScript = `jsx_files=$(find resources/js -name "*.jsx" -type f -not -path "*/node_modules/*") +count=0 +for file in $jsx_files; do + ts_file="\${file%.jsx}.tsx" + if [ ! -f "$ts_file" ]; then + cp "$file" "$ts_file" + count=$((count + 1)) + fi +done +echo "Successfully converted $count .jsx files to .tsx"` + + expect(() => { + parseCommand(bashScript) + }).not.toThrow("Bad substitution: calc.add") + }) + + it("should correctly parse commands with arithmetic expressions", () => { + const result = parseCommand("count=$((count + 1)) && echo $count") + expect(result).toEqual(["count=$((count + 1))", "echo $count"]) + }) + + it("should handle nested arithmetic expressions", () => { + const result = parseCommand("result=$((10 * (5 + 3))) && echo $result") + expect(result).toEqual(["result=$((10 * (5 + 3)))", "echo $result"]) + }) + + it("should handle arithmetic expressions with variables", () => { + const result = parseCommand("total=$((price * quantity + tax))") + expect(result).toEqual(["total=$((price * quantity + tax))"]) + }) + + it("should handle complex parameter expansions without errors", () => { + const commands = [ + "echo ${var:-default}", + "echo ${#array[@]}", + "echo ${var%suffix}", + "echo ${var#prefix}", + "echo ${var/pattern/replacement}", + "echo ${!var}", + "echo ${var:0:5}", + "echo ${var,,}", + "echo ${var^^}", + ] + + commands.forEach((cmd) => { + expect(() => { + parseCommand(cmd) + }).not.toThrow() + }) + }) + + it("should handle process substitutions without errors", () => { + const commands = [ + "diff <(sort file1) <(sort file2)", + "command >(gzip > output.gz)", + "while read line; do echo $line; done < <(cat file)", + ] + + commands.forEach((cmd) => { + expect(() => { + parseCommand(cmd) + }).not.toThrow() + }) + }) + + it("should handle special bash variables without errors", () => { + const commands = [ + "echo $?", + "echo $!", + "echo $#", + "echo $$", + "echo $@", + "echo $*", + "echo $-", + "echo $0", + "echo $1 $2 $3", + ] + + commands.forEach((cmd) => { + expect(() => { + parseCommand(cmd) + }).not.toThrow() + }) + }) + + it("should handle mixed complex bash constructs", () => { + const complexCommand = ` + for file in \${files[@]}; do + if [[ -f "\${file%.txt}.bak" ]]; then + count=\$((count + 1)) + echo "Processing \${file} (\$count/\${#files[@]})" + result=\$(process_file "\$file" 2>&1) + if [[ \$? -eq 0 ]]; then + echo "Success: \$result" >(logger) + fi + fi + done + ` + + expect(() => { + parseCommand(complexCommand) + }).not.toThrow() + }) + + it("should handle fallback parsing when shell-quote fails", () => { + // Test a command that might cause shell-quote to fail + const problematicCommand = "echo ${unclosed" + + expect(() => { + const result = parseCommand(problematicCommand) + // Should not throw and should return some result + expect(Array.isArray(result)).toBe(true) + }).not.toThrow() + }) }) describe("isAutoApprovedCommand (legacy behavior)", () => { diff --git a/webview-ui/src/utils/command-validation.ts b/webview-ui/src/utils/command-validation.ts index 1243cd1ee29..1dbc7229433 100644 --- a/webview-ui/src/utils/command-validation.ts +++ b/webview-ui/src/utils/command-validation.ts @@ -76,6 +76,10 @@ export function parseCommand(command: string): string[] { const subshells: string[] = [] const quotes: string[] = [] const arrayIndexing: string[] = [] + const arithmeticExpressions: string[] = [] + const variables: string[] = [] + const parameterExpansions: string[] = [] + const processSubstitutions: string[] = [] // First handle PowerShell redirections by temporarily replacing them let processedCommand = command.replace(/\d*>&\d*/g, (match) => { @@ -83,10 +87,37 @@ export function parseCommand(command: string): string[] { return `__REDIR_${redirections.length - 1}__` }) - // Handle array indexing expressions: ${array[...]} pattern and partial expressions - processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => { - arrayIndexing.push(match) - return `__ARRAY_${arrayIndexing.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}__` + }) + + // Handle parameter expansions: ${...} patterns (including array indexing) + // This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc. + processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => { + parameterExpansions.push(match) + return `__PARAM_${parameterExpansions.length - 1}__` + }) + + // Handle process substitutions: <(...) and >(...) + processedCommand = processedCommand.replace(/[<>]\([^)]+\)/g, (match) => { + processSubstitutions.push(match) + return `__PROCSUB_${processSubstitutions.length - 1}__` + }) + + // 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}__` + }) + + // 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 @@ -106,7 +137,40 @@ export function parseCommand(command: string): string[] { return `__QUOTE_${quotes.length - 1}__` }) - const tokens = parse(processedCommand) as ShellToken[] + let tokens: ShellToken[] + try { + tokens = parse(processedCommand) as ShellToken[] + } catch (error: any) { + // If shell-quote fails to parse, fall back to simple splitting + console.warn("shell-quote parse error:", error.message, "for command:", processedCommand) + + // Simple fallback: split by common operators + const fallbackCommands = processedCommand + .split(/(?:&&|\|\||;|\|)/) + .map((cmd) => cmd.trim()) + .filter((cmd) => cmd.length > 0) + + // Restore all placeholders for each command + return fallbackCommands.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)]) + // Restore arithmetic expressions + result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) + // Restore parameter expansions + result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)]) + // Restore process substitutions + result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)]) + // Restore variable references + result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) + return result + }) + } + const commands: string[] = [] let currentCommand: string[] = [] @@ -151,6 +215,14 @@ export function parseCommand(command: string): string[] { result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) // Restore array indexing expressions result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) + // Restore arithmetic expressions + result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) + // Restore parameter expansions + result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)]) + // Restore process substitutions + result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)]) + // Restore variable references + result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) return result }) }