Files

158 lines
6.0 KiB
JavaScript

/** 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 };