* Extract literal content from a double-quoted string node. A `string` node's * children are `"` delimiters, `string_content` literals, and possibly * expansion nodes. * * tree-sitter quirk: literal newlines inside double quotes are NOT included * in `string_content` node text. bash preserves t
( node: Node, innerCommands: SimpleCommand[], varScope: Map<string, string>, )
| 1506 | * match what bash actually sees. |
| 1507 | */ |
| 1508 | function walkString( |
| 1509 | node: Node, |
| 1510 | innerCommands: SimpleCommand[], |
| 1511 | varScope: Map<string, string>, |
| 1512 | ): string | ParseForSecurityResult { |
| 1513 | let result = '' |
| 1514 | let cursor = -1 |
| 1515 | // SECURITY: Track whether the string contains a runtime-unknown |
| 1516 | // placeholder ($() output or unknown-value tracked var) vs any literal |
| 1517 | // content. A string that is ONLY a placeholder (`"$(cmd)"`, `"$VAR"` |
| 1518 | // where VAR holds an unknown sentinel) produces an argv element that IS |
| 1519 | // the placeholder — which downstream path validation resolves as a |
| 1520 | // relative filename within cwd, bypassing the check. `cd "$(echo /etc)"` |
| 1521 | // would pass validation but runtime-cd into /etc. We reject |
| 1522 | // solo-placeholder strings; placeholders mixed with literal content |
| 1523 | // (`"prefix: $(cmd)"`) are safe — runtime value can't equal a bare path. |
| 1524 | let sawDynamicPlaceholder = false |
| 1525 | let sawLiteralContent = false |
| 1526 | for (const child of node.children) { |
| 1527 | if (!child) continue |
| 1528 | // Index gap between this child and the previous one = dropped newline(s). |
| 1529 | // Ignore the gap before the first non-delimiter child (cursor === -1). |
| 1530 | // Skip gap-fill for `"` delimiters: a gap before the closing `"` is the |
| 1531 | // tree-sitter whitespace-only-string quirk (space/tab, not newline) — let |
| 1532 | // the Fix C check below catch it as too-complex instead of mis-filling |
| 1533 | // with `\n` and diverging from bash. |
| 1534 | if (cursor !== -1 && child.startIndex > cursor && child.type !== '"') { |
| 1535 | result += '\n'.repeat(child.startIndex - cursor) |
| 1536 | sawLiteralContent = true |
| 1537 | } |
| 1538 | cursor = child.endIndex |
| 1539 | switch (child.type) { |
| 1540 | case '"': |
| 1541 | // Reset cursor after opening quote so the gap between `"` and the |
| 1542 | // first content child is captured. |
| 1543 | cursor = child.endIndex |
| 1544 | break |
| 1545 | case 'string_content': |
| 1546 | // Bash double-quote escape rules (NOT the generic /\\(.)/g used for |
| 1547 | // unquoted words in walkArgument): inside "...", a backslash only |
| 1548 | // escapes $ ` " \ — other sequences like \n stay literal. So |
| 1549 | // `"fix \"bug\""` → `fix "bug"`, but `"a\nb"` → `a\nb` (backslash |
| 1550 | // kept). tree-sitter preserves the raw escapes in .text; we resolve |
| 1551 | // them here so argv matches what bash actually passes. |
| 1552 | result += child.text.replace(/\\([$`"\\])/g, '$1') |
| 1553 | sawLiteralContent = true |
| 1554 | break |
| 1555 | case DOLLAR: |
| 1556 | // A bare dollar sign before closing quote or a non-name char is |
| 1557 | // literal in bash. tree-sitter emits it as a standalone node. |
| 1558 | result += DOLLAR |
| 1559 | sawLiteralContent = true |
| 1560 | break |
| 1561 | case 'command_substitution': { |
| 1562 | // Carve-out: `$(cat <<'EOF' ... EOF)` is safe. The quoted-delimiter |
| 1563 | // heredoc body is literal (no expansion), and `cat` just prints it. |
| 1564 | // The substitution result is therefore a known static string. This |
| 1565 | // pattern is the idiomatic way to pass multi-line content to tools |
no test coverage detected