(command: string)
| 12 | * causing the stdin redirect to apply to eval itself rather than the first command. |
| 13 | */ |
| 14 | export function rearrangePipeCommand(command: string): string { |
| 15 | // Skip if command has backticks - shell-quote doesn't handle them well |
| 16 | if (command.includes('`')) { |
| 17 | return quoteWithEvalStdinRedirect(command) |
| 18 | } |
| 19 | |
| 20 | // Skip if command has command substitution - shell-quote parses $() incorrectly, |
| 21 | // treating ( and ) as separate operators instead of recognizing command substitution |
| 22 | if (command.includes('$(')) { |
| 23 | return quoteWithEvalStdinRedirect(command) |
| 24 | } |
| 25 | |
| 26 | // Skip if command references shell variables ($VAR, ${VAR}). shell-quote's parse() |
| 27 | // expands these to empty string when no env is passed, silently dropping the |
| 28 | // reference. Even if we preserved the token via an env function, quote() would |
| 29 | // then escape the $ during rebuild, preventing runtime expansion. See #9732. |
| 30 | if (/\$[A-Za-z_{]/.test(command)) { |
| 31 | return quoteWithEvalStdinRedirect(command) |
| 32 | } |
| 33 | |
| 34 | // Skip if command contains bash control structures (for/while/until/if/case/select) |
| 35 | // shell-quote cannot parse these correctly and will incorrectly find pipes inside |
| 36 | // the control structure body, breaking the command when rearranged |
| 37 | if (containsControlStructure(command)) { |
| 38 | return quoteWithEvalStdinRedirect(command) |
| 39 | } |
| 40 | |
| 41 | // Join continuation lines before parsing: shell-quote doesn't handle \<newline> |
| 42 | // and produces empty string tokens for each occurrence, causing spurious empty |
| 43 | // arguments in the reconstructed command |
| 44 | const joined = joinContinuationLines(command) |
| 45 | |
| 46 | // shell-quote treats bare newlines as whitespace, not command separators. |
| 47 | // Parsing+rebuilding 'cmd1 | head\ncmd2 | grep' yields 'cmd1 | head cmd2 | grep', |
| 48 | // silently merging pipelines. Line-continuation (\<newline>) is already stripped |
| 49 | // above; any remaining newline is a real separator. Bail to the eval fallback, |
| 50 | // which preserves the newline inside a single-quoted arg. See #32515. |
| 51 | if (joined.includes('\n')) { |
| 52 | return quoteWithEvalStdinRedirect(command) |
| 53 | } |
| 54 | |
| 55 | // SECURITY: shell-quote treats \' inside single quotes as an escape, but |
| 56 | // bash treats it as literal \ followed by a closing quote. The pattern |
| 57 | // '\' <payload> '\' makes shell-quote merge <payload> into the quoted |
| 58 | // string, hiding operators like ; from the token stream. Rebuilding from |
| 59 | // that merged token can expose the operators when bash re-parses. |
| 60 | if (hasShellQuoteSingleQuoteBug(joined)) { |
| 61 | return quoteWithEvalStdinRedirect(command) |
| 62 | } |
| 63 | |
| 64 | const parseResult = tryParseShellCommand(joined) |
| 65 | |
| 66 | // If parsing fails (malformed syntax), fall back to quoting the whole command |
| 67 | if (!parseResult.success) { |
| 68 | return quoteWithEvalStdinRedirect(command) |
| 69 | } |
| 70 | |
| 71 | const parsed = parseResult.tokens |
no test coverage detected