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 wellif (command.includes('`')) {return quoteWithEvalStdinRedirect(command)}// Skip if command has command substitution - shell-quote parses $() incorrectly,// treating ( and ) as separate operators instead of recognizing command substitutionif (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 rearrangedif (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 commandconst 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 commandif (!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_pipelineconst 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 commandlet seenNonEnvVar = falsefor (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 redirectionsif (op.op === '>&' &&typeof target === 'string' &&/^[012]$/.test(target)) {parts.push(`${entry}>&${target}`)i += 2continue}// Handle 2>/dev/null style redirectionsif (op.op === '>' && target === '/dev/null') {parts.push(`${entry}>/dev/null`)i += 2continue}// 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 += 2continue}}}// Handle regular entriesif (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 partsconst eqIndex = entry.indexOf('=')const name = entry.slice(0, eqIndex)const value = entry.slice(eqIndex + 1)// Quote the value part to handle spaces and special charactersconst quotedValue = quote([value])parts.push(`${name}=${quotedValue}`)} else {// Once we see a non-env-var string, all subsequent strings are argumentsseenNonEnvVar = trueparts.push(quote([entry]))}} else if (isOperator(entry)) {// Special handling for glob operatorsif (entry.op === 'glob' && 'pattern' in entry) {// Don't quote glob patterns - they need to remain as-is for shell expansionparts.push(entry.pattern as string)} else {parts.push(entry.op)// Reset after command separators - the next command can have its own env varsif (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 newlineif (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 separatorreturn match}})}
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。