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
This commit is contained in:
hannesrudolph 2025-07-23 12:21:02 -06:00
parent 55d0ff3f69
commit 17dbda3d4d

View file

@ -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: [],
}
}
}