diff --git a/scripts/hooks/gateguard-heredoc.js b/scripts/hooks/gateguard-heredoc.js index 76de206f3..998a7bec1 100644 --- a/scripts/hooks/gateguard-heredoc.js +++ b/scripts/hooks/gateguard-heredoc.js @@ -124,30 +124,6 @@ function findHeredocs(line) { return heredocs.includes(null) ? null : heredocs; } -/** - * Iterate over executable substitutions in an unquoted heredoc. - * - * @param {string} text - * @returns {Generator} - */ -function* iterateHeredocCommandSubstitutions(text) { - let escaped = false; - for (let i = 0; i < text.length; i += 1) { - const ch = text[i]; - if (escaped) { - escaped = false; - continue; - } - if (ch === '\\') { - escaped = true; - continue; - } - if (ch === '`' || (ch === '$' && text[i + 1] === '(')) { - yield* extractCommandSubstitutions(text.slice(i)); - } - } -} - /** * Extract executable substitutions from an unquoted heredoc. Quote characters * in its payload are literal and do not suppress expansion. @@ -157,7 +133,58 @@ function* iterateHeredocCommandSubstitutions(text) { */ function extractHeredocCommandSubstitutions(body) { const text = body.join('\n'); - return [...new Set(iterateHeredocCommandSubstitutions(text))]; + return [...new Set(extractCommandSubstitutions(text, { literalOuterQuotes: true }))]; +} + +/** + * Consume one heredoc body and return its immutable parser result. + * + * @param {string[]} lines + * @param {number} startIndex + * @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }} heredoc + * @returns {{ nextIndex: number, substitutions: string[] } | null} + */ +function consumeHeredocBody(lines, startIndex, heredoc) { + for (let lineIndex = startIndex; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]; + if (!heredoc.quoted && /\\$/.test(line)) return null; + const delimiterLine = heredoc.stripTabs ? line.replace(/^\t+/, '') : line; + if (delimiterLine !== heredoc.delimiter) continue; + const body = lines.slice(startIndex, lineIndex); + const substitutions = heredoc.quoted ? [] : extractHeredocCommandSubstitutions(body); + return { nextIndex: lineIndex + 1, substitutions }; + } + return null; +} + +/** + * @param {string[]} lines + * @param {number} startIndex + * @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }[]} heredocs + * @returns {{ nextIndex: number, chunks: object | null } | null} + */ +function consumeHeredocBodies(lines, startIndex, heredocs) { + let state = { nextIndex: startIndex, chunks: null }; + for (const heredoc of heredocs) { + const consumed = consumeHeredocBody(lines, state.nextIndex, heredoc); + if (!consumed) return null; + state = { + nextIndex: consumed.nextIndex, + chunks: consumed.substitutions.length === 0 ? state.chunks : { substitutions: consumed.substitutions, previous: state.chunks } + }; + } + return state; +} + +/** @returns {Generator} */ +function* iterateSubstitutionChunks(chunks) { + let ordered = null; + for (let chunk = chunks; chunk; chunk = chunk.previous) { + ordered = { substitutions: chunk.substitutions, next: ordered }; + } + for (let chunk = ordered; chunk; chunk = chunk.next) { + yield* chunk.substitutions; + } } /** @@ -174,62 +201,26 @@ function extractHeredocCommandSubstitutions(body) { function stripHeredocBodies(input) { const raw = String(input || ''); const lines = raw.split(/\r?\n/); - let pending = []; - let pendingIndex = 0; - let bodyStartIndex = -1; let headerIndex = -1; - let trailingStartIndex = lines.length; - let substitutionText = ''; - let substitutionCount = 0; - let completedHeredoc = false; + let pending = []; for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { const line = lines[lineIndex]; - if (pendingIndex < pending.length) { - const current = pending[pendingIndex]; - if (!current.quoted && /\\$/.test(line)) return raw; - const delimiterLine = current.stripTabs ? line.replace(/^\t+/, '') : line; - if (delimiterLine === current.delimiter) { - if (!current.quoted) { - for (const substitution of extractHeredocCommandSubstitutions(lines.slice(bodyStartIndex, lineIndex))) { - substitutionText = substitutionCount === 0 ? substitution : `${substitutionText}\n${substitution}`; - substitutionCount += 1; - } - } - pendingIndex += 1; - bodyStartIndex = lineIndex + 1; - if (pendingIndex === pending.length) { - completedHeredoc = true; - trailingStartIndex = lineIndex + 1; - } - } - continue; - } - if (completedHeredoc && line.trim()) return raw; - if (completedHeredoc) continue; const heredocs = findHeredocs(line); if (heredocs === null) return raw; if (heredocs.length > 0 && !isProvenPassiveHeredocLine(line)) return raw; if (heredocs.length > 0) { pending = heredocs; - pendingIndex = 0; - bodyStartIndex = lineIndex + 1; headerIndex = lineIndex; + break; } } - if (pendingIndex < pending.length) return raw; if (headerIndex < 0) return lines.join('\n'); - - const prefix = lines.slice(0, headerIndex + 1).join('\n'); - const trailingCount = lines.length - trailingStartIndex; - const trailing = lines.slice(trailingStartIndex).join('\n'); - let result = prefix; - let resultCount = headerIndex + 1; - if (substitutionCount > 0) { - result = resultCount === 0 ? substitutionText : `${result}\n${substitutionText}`; - resultCount += substitutionCount; - } - if (trailingCount > 0) result = resultCount === 0 ? trailing : `${result}\n${trailing}`; - return result; + const consumed = consumeHeredocBodies(lines, headerIndex + 1, pending); + if (!consumed) return raw; + const trailing = lines.slice(consumed.nextIndex); + if (trailing.some(line => line.trim())) return raw; + const substitutions = iterateSubstitutionChunks(consumed.chunks); + return [...lines.slice(0, headerIndex + 1), ...substitutions, ...trailing].join('\n'); } module.exports = { stripHeredocBodies }; diff --git a/scripts/lib/shell-substitution.js b/scripts/lib/shell-substitution.js index 0251e74e2..a2241770d 100644 --- a/scripts/lib/shell-substitution.js +++ b/scripts/lib/shell-substitution.js @@ -1,126 +1,97 @@ 'use strict'; -/** - * Extract executable command-substitution bodies from a shell line. - * - * Single quotes are literal, so substitutions inside them are ignored; - * double quotes still permit substitutions, so those bodies are scanned - * before quoted text is stripped. Returns each substitution body plus - * any nested substitutions discovered recursively. - * - * Originally introduced in scripts/hooks/gateguard-fact-force.js - * (PR #1853 round 2). Extracted to a shared lib so other PreToolUse - * hooks that need the same "scan inside `$(...)` and backticks" - * behavior can reuse it without duplicating the parser. - * - * @param {string} input - * @returns {string[]} - */ -function extractCommandSubstitutions(input) { - const source = String(input || ''); - const substitutions = []; +/** @returns {{ body: string, endIndex: number }} */ +function readBacktickSubstitution(source, startIndex) { + let body = ''; + let endIndex = startIndex + 1; + while (endIndex < source.length) { + const inner = source[endIndex]; + if (inner === '\\') { + const escaped = source[endIndex + 1]; + body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`; + endIndex += escaped === undefined ? 1 : 2; + continue; + } + if (inner === '`') break; + body = `${body}${inner}`; + endIndex += 1; + } + return { body, endIndex }; +} + +/** @returns {{ body: string, endIndex: number }} */ +function readDollarSubstitution(source, startIndex) { + let body = ''; + let depth = 1; let inSingle = false; let inDouble = false; + let endIndex = startIndex + 2; + while (endIndex < source.length && depth > 0) { + const inner = source[endIndex]; + if (inner === '\\' && !inSingle) { + const escaped = source[endIndex + 1]; + body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`; + endIndex += escaped === undefined ? 1 : 2; + continue; + } + if (inner === "'" && !inDouble) inSingle = !inSingle; + else if (inner === '"' && !inSingle) inDouble = !inDouble; + else if (!inSingle && !inDouble && inner === '(') depth += 1; + else if (!inSingle && !inDouble && inner === ')') depth -= 1; + if (depth > 0) body = `${body}${inner}`; + endIndex += depth > 0 ? 1 : 0; + } + return { body, endIndex }; +} - for (let i = 0; i < source.length; i++) { +/** + * Iterate over command-substitution bodies, followed by nested bodies. + * Quote characters in an unquoted heredoc are literal only at the outer level; + * substitutions still use normal shell quote semantics internally. + * + * @param {string} input + * @param {{ literalOuterQuotes?: boolean }} [options] + * @returns {Generator} + */ +function* iterateCommandSubstitutions(input, options = {}) { + const source = String(input || ''); + const literalOuterQuotes = options.literalOuterQuotes === true; + let inSingle = false; + let inDouble = false; + for (let i = 0; i < source.length; i += 1) { const ch = source[i]; - const prev = source[i - 1]; - if (ch === '\\' && !inSingle) { i += 1; continue; } - - if (ch === "'" && !inDouble && prev !== '\\') { + if (!literalOuterQuotes && ch === "'" && !inDouble) { inSingle = !inSingle; continue; } - - if (ch === '"' && !inSingle && prev !== '\\') { + if (!literalOuterQuotes && ch === '"' && !inSingle) { inDouble = !inDouble; continue; } - - if (inSingle) { - continue; - } - - if (ch === '`') { - let body = ''; - i += 1; - while (i < source.length) { - const inner = source[i]; - if (inner === '\\') { - body += inner; - if (i + 1 < source.length) { - body += source[i + 1]; - i += 2; - } else { - // Trailing backslash at end of an unterminated span: advance past - // it so it is not appended a second time by the fallthrough below. - i += 1; - } - continue; - } - if (inner === '`') { - break; - } - body += inner; - i += 1; - } - if (body.trim()) { - substitutions.push(body); - substitutions.push(...extractCommandSubstitutions(body)); - } - continue; - } - - if (ch === '$' && source[i + 1] === '(') { - let depth = 1; - let body = ''; - let bodyInSingle = false; - let bodyInDouble = false; - i += 2; - while (i < source.length && depth > 0) { - const inner = source[i]; - const innerPrev = source[i - 1]; - if (inner === '\\' && !bodyInSingle) { - body += inner; - if (i + 1 < source.length) { - body += source[i + 1]; - i += 2; - } else { - // Trailing backslash at end of an unterminated span: advance past - // it so it is not appended a second time by the fallthrough below. - i += 1; - } - continue; - } - if (inner === "'" && !bodyInDouble && innerPrev !== '\\') { - bodyInSingle = !bodyInSingle; - } else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') { - bodyInDouble = !bodyInDouble; - } else if (!bodyInSingle && !bodyInDouble) { - if (inner === '(') { - depth += 1; - } else if (inner === ')') { - depth -= 1; - if (depth === 0) { - break; - } - } - } - body += inner; - i += 1; - } - if (body.trim()) { - substitutions.push(body); - substitutions.push(...extractCommandSubstitutions(body)); - } - } + if (inSingle) continue; + const span = ch === '`' ? readBacktickSubstitution(source, i) : null; + const substitution = ch === '$' && source[i + 1] === '(' ? readDollarSubstitution(source, i) : span; + if (!substitution) continue; + i = substitution.endIndex; + if (!substitution.body.trim()) continue; + yield substitution.body; + yield* iterateCommandSubstitutions(substitution.body); } +} - return substitutions; +/** + * Extract executable command-substitution bodies from a shell line. + * + * @param {string} input + * @param {{ literalOuterQuotes?: boolean }} [options] + * @returns {string[]} + */ +function extractCommandSubstitutions(input, options = {}) { + return [...iterateCommandSubstitutions(input, options)]; } /** diff --git a/tests/lib/shell-substitution.test.js b/tests/lib/shell-substitution.test.js index 8b0be6cac..f64c90419 100644 --- a/tests/lib/shell-substitution.test.js +++ b/tests/lib/shell-substitution.test.js @@ -1,10 +1,6 @@ 'use strict'; const assert = require('assert'); -const { - extractCommandSubstitutions, - extractSubshellGroups, - extractBraceGroups, -} = require('../../scripts/lib/shell-substitution'); +const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../../scripts/lib/shell-substitution'); console.log('=== Testing shell-substitution.js ===\n'); @@ -66,6 +62,12 @@ test('double-quoted body extracted, single-quoted body ignored', () => { test('single quotes inside a $() body are preserved', () => { assert.deepStrictEqual(extractCommandSubstitutions("x=$(echo 'a b')"), ["echo 'a b'"]); }); +test('literal outer quotes do not suppress substitutions', () => { + assert.deepStrictEqual(extractCommandSubstitutions("'$(whoami)'", { literalOuterQuotes: true }), ['whoami']); +}); +test('literal outer quotes preserve shell quoting inside a substitution', () => { + assert.deepStrictEqual(extractCommandSubstitutions("'$(echo '$(ignored)')'", { literalOuterQuotes: true }), ["echo '$(ignored)'"]); +}); console.log('\nextractCommandSubstitutions - escaped substitutions:'); test('escaped \\$() is NOT extracted (literal dollar)', () => {