chore: initialize recovered claude workspace
This commit is contained in:
294
src/utils/bash/bashPipeCommand.ts
Normal file
294
src/utils/bash/bashPipeCommand.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
hasMalformedTokens,
|
||||
hasShellQuoteSingleQuoteBug,
|
||||
type ParseEntry,
|
||||
quote,
|
||||
tryParseShellCommand,
|
||||
} from './shellQuote.js'
|
||||
|
||||
/**
|
||||
* Rearranges a command with pipes to place stdin redirect after the first command.
|
||||
* This fixes an issue where eval treats the entire piped command as a single unit,
|
||||
* causing the stdin redirect to apply to eval itself rather than the first command.
|
||||
*/
|
||||
export function rearrangePipeCommand(command: string): string {
|
||||
// Skip if command has backticks - shell-quote doesn't handle them well
|
||||
if (command.includes('`')) {
|
||||
return quoteWithEvalStdinRedirect(command)
|
||||
}
|
||||
|
||||
// Skip if command has command substitution - shell-quote parses $() incorrectly,
|
||||
// treating ( and ) as separate operators instead of recognizing command substitution
|
||||
if (command.includes('$(')) {
|
||||
return quoteWithEvalStdinRedirect(command)
|
||||
}
|
||||
|
||||
// Skip if command references shell variables ($VAR, ${VAR}). shell-quote's parse()
|
||||
// expands these to empty string when no env is passed, silently dropping the
|
||||
// reference. Even if we preserved the token via an env function, quote() would
|
||||
// then escape the $ during rebuild, preventing runtime expansion. See #9732.
|
||||
if (/\$[A-Za-z_{]/.test(command)) {
|
||||
return quoteWithEvalStdinRedirect(command)
|
||||
}
|
||||
|
||||
// Skip if command contains bash control structures (for/while/until/if/case/select)
|
||||
// shell-quote cannot parse these correctly and will incorrectly find pipes inside
|
||||
// the control structure body, breaking the command when rearranged
|
||||
if (containsControlStructure(command)) {
|
||||
return quoteWithEvalStdinRedirect(command)
|
||||
}
|
||||
|
||||
// Join continuation lines before parsing: shell-quote doesn't handle \<newline>
|
||||
// and produces empty string tokens for each occurrence, causing spurious empty
|
||||
// arguments in the reconstructed command
|
||||
const joined = joinContinuationLines(command)
|
||||
|
||||
// shell-quote treats bare newlines as whitespace, not command separators.
|
||||
// Parsing+rebuilding 'cmd1 | head\ncmd2 | grep' yields 'cmd1 | head cmd2 | grep',
|
||||
// silently merging pipelines. Line-continuation (\<newline>) is already stripped
|
||||
// above; any remaining newline is a real separator. Bail to the eval fallback,
|
||||
// which preserves the newline inside a single-quoted arg. See #32515.
|
||||
if (joined.includes('\n')) {
|
||||
return quoteWithEvalStdinRedirect(command)
|
||||
}
|
||||
|
||||
// SECURITY: shell-quote treats \' inside single quotes as an escape, but
|
||||
// bash treats it as literal \ followed by a closing quote. The pattern
|
||||
// '\' <payload> '\' makes shell-quote merge <payload> into the quoted
|
||||
// string, hiding operators like ; from the token stream. Rebuilding from
|
||||
// that merged token can expose the operators when bash re-parses.
|
||||
if (hasShellQuoteSingleQuoteBug(joined)) {
|
||||
return quoteWithEvalStdinRedirect(command)
|
||||
}
|
||||
|
||||
const parseResult = tryParseShellCommand(joined)
|
||||
|
||||
// If parsing fails (malformed syntax), fall back to quoting the whole command
|
||||
if (!parseResult.success) {
|
||||
return quoteWithEvalStdinRedirect(command)
|
||||
}
|
||||
|
||||
const parsed = parseResult.tokens
|
||||
|
||||
// SECURITY: shell-quote tokenizes differently from bash. Input like
|
||||
// `echo {"hi":\"hi;calc.exe"}` is a bash syntax error (unbalanced quote),
|
||||
// but shell-quote parses it into tokens with `;` as an operator and
|
||||
// `calc.exe` as a separate word. Rebuilding from those tokens produces
|
||||
// valid bash that executes `calc.exe` — turning a syntax error into an
|
||||
// injection. Unbalanced delimiters in a string token signal this
|
||||
// misparsing; fall back to whole-command quoting, which preserves the
|
||||
// original (bash then rejects it with the same syntax error it would have
|
||||
// raised without us).
|
||||
if (hasMalformedTokens(joined, parsed)) {
|
||||
return quoteWithEvalStdinRedirect(command)
|
||||
}
|
||||
|
||||
const firstPipeIndex = findFirstPipeOperator(parsed)
|
||||
|
||||
if (firstPipeIndex <= 0) {
|
||||
return quoteWithEvalStdinRedirect(command)
|
||||
}
|
||||
|
||||
// Rebuild: first_command < /dev/null | rest_of_pipeline
|
||||
const parts = [
|
||||
...buildCommandParts(parsed, 0, firstPipeIndex),
|
||||
'< /dev/null',
|
||||
...buildCommandParts(parsed, firstPipeIndex, parsed.length),
|
||||
]
|
||||
|
||||
return singleQuoteForEval(parts.join(' '))
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of the first pipe operator in parsed shell command
|
||||
*/
|
||||
function findFirstPipeOperator(parsed: ParseEntry[]): number {
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const entry = parsed[i]
|
||||
if (isOperator(entry, '|')) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds command parts from parsed entries, handling strings and operators.
|
||||
* Special handling for file descriptor redirections to preserve them as single units.
|
||||
*/
|
||||
function buildCommandParts(
|
||||
parsed: ParseEntry[],
|
||||
start: number,
|
||||
end: number,
|
||||
): string[] {
|
||||
const parts: string[] = []
|
||||
// Track if we've seen a non-env-var string token yet
|
||||
// Environment variables are only valid at the start of a command
|
||||
let seenNonEnvVar = false
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const entry = parsed[i]
|
||||
|
||||
// Check for file descriptor redirections (e.g., 2>&1, 2>/dev/null)
|
||||
if (
|
||||
typeof entry === 'string' &&
|
||||
/^[012]$/.test(entry) &&
|
||||
i + 2 < end &&
|
||||
isOperator(parsed[i + 1])
|
||||
) {
|
||||
const op = parsed[i + 1] as { op: string }
|
||||
const target = parsed[i + 2]
|
||||
|
||||
// Handle 2>&1 style redirections
|
||||
if (
|
||||
op.op === '>&' &&
|
||||
typeof target === 'string' &&
|
||||
/^[012]$/.test(target)
|
||||
) {
|
||||
parts.push(`${entry}>&${target}`)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle 2>/dev/null style redirections
|
||||
if (op.op === '>' && target === '/dev/null') {
|
||||
parts.push(`${entry}>/dev/null`)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle 2> &1 style (space between > and &1)
|
||||
if (
|
||||
op.op === '>' &&
|
||||
typeof target === 'string' &&
|
||||
target.startsWith('&')
|
||||
) {
|
||||
const fd = target.slice(1)
|
||||
if (/^[012]$/.test(fd)) {
|
||||
parts.push(`${entry}>&${fd}`)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular entries
|
||||
if (typeof entry === 'string') {
|
||||
// Environment variable assignments are only valid at the start of a command,
|
||||
// before any non-env-var tokens (the actual command and its arguments)
|
||||
const isEnvVar = !seenNonEnvVar && isEnvironmentVariableAssignment(entry)
|
||||
|
||||
if (isEnvVar) {
|
||||
// For env var assignments, we need to preserve the = but quote the value if needed
|
||||
// Split into name and value parts
|
||||
const eqIndex = entry.indexOf('=')
|
||||
const name = entry.slice(0, eqIndex)
|
||||
const value = entry.slice(eqIndex + 1)
|
||||
|
||||
// Quote the value part to handle spaces and special characters
|
||||
const quotedValue = quote([value])
|
||||
parts.push(`${name}=${quotedValue}`)
|
||||
} else {
|
||||
// Once we see a non-env-var string, all subsequent strings are arguments
|
||||
seenNonEnvVar = true
|
||||
parts.push(quote([entry]))
|
||||
}
|
||||
} else if (isOperator(entry)) {
|
||||
// Special handling for glob operators
|
||||
if (entry.op === 'glob' && 'pattern' in entry) {
|
||||
// Don't quote glob patterns - they need to remain as-is for shell expansion
|
||||
parts.push(entry.pattern as string)
|
||||
} else {
|
||||
parts.push(entry.op)
|
||||
// Reset after command separators - the next command can have its own env vars
|
||||
if (isCommandSeparator(entry.op)) {
|
||||
seenNonEnvVar = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is an environment variable assignment (VAR=value)
|
||||
* Environment variable names must start with letter or underscore,
|
||||
* followed by letters, numbers, or underscores
|
||||
*/
|
||||
function isEnvironmentVariableAssignment(str: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an operator is a command separator that starts a new command context.
|
||||
* After these operators, environment variable assignments are valid again.
|
||||
*/
|
||||
function isCommandSeparator(op: string): boolean {
|
||||
return op === '&&' || op === '||' || op === ';'
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a parsed entry is an operator
|
||||
*/
|
||||
function isOperator(entry: unknown, op?: string): entry is { op: string } {
|
||||
if (!entry || typeof entry !== 'object' || !('op' in entry)) {
|
||||
return false
|
||||
}
|
||||
return op ? entry.op === op : true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a command contains bash control structures that shell-quote cannot parse.
|
||||
* These include for/while/until/if/case/select loops and conditionals.
|
||||
* We match keywords followed by whitespace to avoid false positives with commands
|
||||
* or arguments that happen to contain these words.
|
||||
*/
|
||||
function containsControlStructure(command: string): boolean {
|
||||
return /\b(for|while|until|if|case|select)\s/.test(command)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quotes a command and adds `< /dev/null` as a shell redirect on eval, rather than
|
||||
* as an eval argument. This is critical for pipe commands where we can't parse the
|
||||
* pipe boundary (e.g., commands with $(), backticks, or control structures).
|
||||
*
|
||||
* Using `singleQuoteForEval(cmd) + ' < /dev/null'` produces: eval 'cmd' < /dev/null
|
||||
* → eval's stdin is /dev/null, eval evaluates 'cmd', pipes inside work correctly
|
||||
*
|
||||
* The previous approach `quote([cmd, '<', '/dev/null'])` produced: eval 'cmd' \< /dev/null
|
||||
* → eval concatenates args to 'cmd < /dev/null', redirect applies to LAST pipe command
|
||||
*/
|
||||
function quoteWithEvalStdinRedirect(command: string): string {
|
||||
return singleQuoteForEval(command) + ' < /dev/null'
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-quote a string for use as an eval argument. Escapes embedded single
|
||||
* quotes via '"'"' (close-sq, literal-sq-in-dq, reopen-sq). Used instead of
|
||||
* shell-quote's quote() which switches to double-quote mode when the input
|
||||
* contains single quotes and then escapes ! -> \!, corrupting jq/awk filters
|
||||
* like `select(.x != .y)` into `select(.x \!= .y)`.
|
||||
*/
|
||||
function singleQuoteForEval(s: string): string {
|
||||
return "'" + s.replace(/'/g, `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins shell continuation lines (backslash-newline) into a single line.
|
||||
* Only joins when there's an odd number of backslashes before the newline
|
||||
* (the last one escapes the newline). Even backslashes pair up as escape
|
||||
* sequences and the newline remains a separator.
|
||||
*/
|
||||
function joinContinuationLines(command: string): string {
|
||||
return command.replace(/\\+\n/g, match => {
|
||||
const backslashCount = match.length - 1 // -1 for the newline
|
||||
if (backslashCount % 2 === 1) {
|
||||
// Odd number: last backslash escapes the newline (line continuation)
|
||||
return '\\'.repeat(backslashCount - 1)
|
||||
} else {
|
||||
// Even number: all pair up, newline is a real separator
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user