/** Shared Bash heredoc word parser for descriptor extraction and policy masking. */ function physicalLines(source) { const rawLines = source.match(/[^\n]*\n|[^\n]+$/gu) ?? []; if (rawLines.length === 0) rawLines.push(""); let offset = 0; return rawLines.map((raw) => { const record = { raw, text: raw.replace(/\n$/u, "").replace(/\r$/u, ""), start: offset }; offset += raw.length; return record; }); } function heredocOperator(line) { let quote = null; let arithmeticDepth = 0; for (let index = 0; index < line.length - 1; index += 1) { const character = line[index]; if (quote !== null) { if (character === quote) quote = null; else if (quote === '"' && character === "\\") index += 1; continue; } if (character === "'" || character === '"') { quote = character; continue; } if (character === "\\") { index += 1; continue; } if (character === "#" && (index === 0 || /[ \t;|&()]/u.test(line[index - 1]))) break; if (character === "(" && line[index + 1] === "(") { arithmeticDepth += 1; index += 1; continue; } if (character === ")" && line[index + 1] === ")" && arithmeticDepth > 0) { arithmeticDepth -= 1; index += 1; continue; } if (arithmeticDepth > 0 || character !== "<" || line[index + 1] !== "<") continue; if (line[index - 1] === "<" || line[index + 2] === "<") { index += 1; continue; } return index; } return -1; } function endsWithBashContinuation(line) { let quote = null; for (let index = 0; index < line.length; index += 1) { const character = line[index]; if (quote === null && character === "`") { index += 1; continue; } if (quote === "'") { if (character === "'") quote = null; continue; } if (character === '"') { if (quote === '"') quote = null; else if (quote === null) quote = '"'; continue; } if (character !== "\\") continue; if (index === line.length - 1) return true; if (quote === null || (quote === '"' && '$`"\\'.includes(line[index + 1]))) index += 1; } return false; } function bashLogicalLine(lines, start) { let line = lines[start]; let end = start; while (endsWithBashContinuation(line)) { if (end + 1 >= lines.length) break; line = `${line.slice(0, -1)}${lines[end + 1]}`; end += 1; } return { line, end }; } function bashHeredocOpener(line, operator, label, lineNumber) { let cursor = operator + 2; let stripTabs = false; if (line[cursor] === "-") { stripTabs = true; cursor += 1; } while (line[cursor] === " " || line[cursor] === "\t") cursor += 1; const unsupported = () => { throw new Error(`${label}:${lineNumber}: unsupported Bash heredoc opener`); }; if (cursor >= line.length || line[cursor] === "#") unsupported(); let delimiter = ""; let quotedDelimiter = false; while (cursor < line.length) { const character = line[cursor]; if (character === " " || character === "\t" || ";|&<>".includes(character)) break; if (character === "'" || character === '"') { quotedDelimiter = true; const quote = character; cursor += 1; let closed = false; while (cursor < line.length) { const quoted = line[cursor]; if (quoted === quote) { closed = true; cursor += 1; break; } if (quote === '"' && quoted === "\\") { cursor += 1; if (cursor >= line.length) unsupported(); const escaped = line[cursor]; delimiter += '$`"\\'.includes(escaped) ? escaped : `\\${escaped}`; cursor += 1; continue; } delimiter += quoted; cursor += 1; } if (!closed) unsupported(); continue; } if (character === "\\") { quotedDelimiter = true; cursor += 1; if (cursor >= line.length) unsupported(); delimiter += line[cursor]; cursor += 1; continue; } if (character === "$" || character === "`" || "(){}[]*?".includes(character)) unsupported(); delimiter += character; cursor += 1; } if (delimiter.length === 0) unsupported(); if (heredocOperator(line.slice(cursor)) >= 0) unsupported(); return { delimiter, stripTabs, expandable: !quotedDelimiter }; } function parsedBashHeredocs(source, label) { const records = physicalLines(source); const lines = records.map((record) => record.text); const extracted = []; for (let index = 0; index < lines.length; index += 1) { const logical = bashLogicalLine(lines, index); const operator = heredocOperator(logical.line); if (operator < 0) { index = logical.end; continue; } const opener = index; const { delimiter, stripTabs, expandable } = bashHeredocOpener(logical.line, operator, label, index + 1); index = logical.end; const body = []; const startLine = index + 2; const bodyStart = records[index + 1]?.start ?? source.length; let closed = false; for (index += 1; index < lines.length; index += 1) { const candidate = stripTabs ? lines[index].replace(/^\t+/u, "") : lines[index]; if (candidate === delimiter) { closed = true; break; } body.push(candidate); } const bodyEnd = closed ? records[index].start : source.length; extracted.push({ source: `${body.join("\n")}\n`, label: `${label}:${startLine} Bash heredoc${closed ? "" : " (unclosed)"}`, expandable, closed, bodyStart, bodyEnd, path: label, rawBlock: records.slice(opener, Math.min(index + 1, records.length)).map((record) => record.raw).join(""), }); } return extracted; } function extractBashDocuments(source, label) { return parsedBashHeredocs(source, label).map(({ bodyStart: _start, bodyEnd: _end, closed: _closed, ...document }) => document); } function literalBashHeredocBodyRanges(source, label) { const ranges = []; for (const heredoc of parsedBashHeredocs(source, label)) { if (!heredoc.expandable) { if (!heredoc.closed) throw new Error(`${label}: revision-state policy found an unclosed literal Bash heredoc`); ranges.push({ start: heredoc.bodyStart, end: heredoc.bodyEnd }); } } return ranges; } export { extractBashDocuments, literalBashHeredocBodyRanges };