Files
ECC/scripts/hooks/lib/shell-scan.js
T
affaan-m dee884656e fix(hooks): preserve Git configuration across shell command scopes
Preserve contributor history while applying the exact reviewed current-main repair.

Source-PR: https://github.com/affaan-m/ECC/pull/2965
Source-Parent: ec0753bc7f
Review-Manifest-SHA256: 0172e3e695e672305f9bc41b0d385d2a299d8ec520c763dd9d7f6da743f76971
2026-09-28 05:38:05 -04:00

407 lines
16 KiB
JavaScript

'use strict';
// Finite literal shell scanner for block-no-verify. This is not an interpreter:
// aliases, generated programs, expansion results and foreign languages remain
// opaque. Every scan/queued region spends one shared input-proportional budget.
function createBudget(length) {
let remaining = 24 * (length + 1) + 4096;
return { spend(amount = 1) {
remaining -= amount;
if (remaining < 0) throw new RangeError('Shell scan work budget exceeded');
} };
}
function continuationEnd(input, index) {
if (input[index] !== '\\') return index;
if (input[index + 1] === '\n') return index + 2;
if (input[index + 1] === '\r' && input[index + 2] === '\n') return index + 3;
return index;
}
// Delimiters undergo quote removal, not command expansion. Keeping this small
// reader shared with substitution matching prevents body punctuation becoming
// shell syntax while locating the enclosing execution region.
function heredocDelimiter(input, start, budget) {
if (!input.startsWith('<<', start) || input[start + 2] === '<') return null;
const operator = input[start + 2] === '-' ? '<<-' : '<<';
let i = start + operator.length;
while (i < input.length) {
budget.spend();
if (input[i] === ' ' || input[i] === '\t' || input[i] === '\r') { i++; continue; }
const continued = continuationEnd(input, i);
if (continued === i) break;
i = continued;
}
let value = '';
let quote = null;
let began = false;
let quoted = false;
for (; i < input.length; i++) {
budget.spend();
const c = input[i];
if (quote === "'") {
if (c === "'") quote = null;
else value += c;
continue;
}
if (c === '\\') {
const continued = continuationEnd(input, i);
if (continued !== i) { i = continued - 1; continue; }
began = true; quoted = true;
const next = input[i + 1];
if (next === undefined) { value += c; continue; }
if (quote === '"' && !'"$`\\'.includes(next)) value += '\\';
value += next; i++; continue;
}
if (quote === '"') {
if (c === '"') quote = null;
else value += c;
continue;
}
if (c === "'" || c === '"') { quote = c; began = true; quoted = true; continue; }
if (/[\s;|&()<>]/.test(c) || (!began && c === '#')) break;
began = true; value += c;
}
return began ? { operator, word: { value, quoted }, end: i } : null;
}
function heredocLine(input, start, joinContinuations, budget) {
const parts = [];
let i = start;
let fragment = start;
while (i < input.length && input[i] !== '\n') {
budget.spend();
if (joinContinuations && input[i] === '\\') {
const continued = continuationEnd(input, i);
if (continued !== i) {
budget.spend(i - fragment + 1);
parts.push(input.slice(fragment, i));
i = continued; fragment = i; continue;
}
// An escaped backslash cannot itself quote the following newline. Keep
// the pair unchanged; only the unpaired final slash joins physical lines.
if (i + 1 < input.length) { i += 2; continue; }
}
i++;
}
budget.spend(3 * (i - start + 1));
parts.push(input.slice(fragment, i));
const newline = i < input.length;
return { text: parts.join(''), newline, end: newline ? i + 1 : i };
}
function heredocBody(input, start, redirect, budget) {
const lines = [];
let length = 0;
let i = start;
while (i < input.length) {
// Bash removes unquoted backslash-newline before testing the ending
// delimiter. Quoted delimiters retain physical lines. The original end
// offset is kept separately so the next actual command is never swallowed.
const line = heredocLine(input, i, !redirect.word.quoted, budget);
budget.spend(2 * (line.text.length + 1));
const text = redirect.operator === '<<-' ? line.text.replace(/^\t+/, '') : line.text;
i = line.end;
if (text.replace(/\r$/, '') === redirect.word.value) break;
lines.push(text);
length += text.length;
if (line.newline) { lines.push('\n'); length++; }
}
budget.spend(length + 1);
return { body: lines.join(''), end: i };
}
function legacyRegion(input, start, budget) {
const decoded = [];
let i = start + 1;
for (; i < input.length; i++) {
budget.spend();
const c = input[i];
if (c === '`') break;
if (c === '\\' && '$`\\\n'.includes(input[i + 1] || '\u0000')) {
// One old-style substitution layer only. Escapes outside an executable
// backtick region are still handled by the outer lexer as literal data.
const next = input[++i];
if (next !== '\n') decoded.push(next);
} else decoded.push(c);
}
budget.spend(decoded.length + 1);
return { text: decoded.join(''), end: i < input.length ? i + 1 : i };
}
// Locate a nested execution region without evaluating any supplied text.
// Balanced substitutions have independent quote state; malformed regions extend
// to EOF and remain conservatively inspectable instead of silently disappearing.
function executionRegion(input, start, budget) {
if (input[start] === '`') return legacyRegion(input, start, budget);
const from = start + 2;
const frame = () => ({ quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] });
const stack = [frame()];
function endWord(state) {
if (!state.word) return;
const phase = state.cases[state.cases.length - 1];
if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop();
else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject');
else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in';
else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern';
state.commandStart = false;
state.word = ''; state.quotedWord = false;
}
for (let i = from; i < input.length; i++) {
budget.spend();
const state = stack[stack.length - 1];
const c = input[i];
if (state.quote === "'" || state.quote === "$'") {
if (state.quote === "$'" && c === '\\' && i + 1 < input.length) { i++; continue; }
if (c === "'") state.quote = null;
continue;
}
if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; i++; continue; }
if (c === '$' && input[i + 1] === '(') {
state.word += '$()'; state.quotedWord = true; stack.push(frame()); i++; continue;
}
if (c === '`') {
const region = legacyRegion(input, i, budget);
state.word += '`'; state.quotedWord = true; i = region.end - 1; continue;
}
if (state.quote === '"') {
if (c === '"') state.quote = null;
continue;
}
if (c === '$' && input[i + 1] === "'") {
state.quote = "$'"; state.word += "$'"; state.quotedWord = true; i++; continue;
}
if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; continue; }
if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) {
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
i--; // Process the newline, including any pending heredoc bodies.
continue;
}
if (c === '<' && input.startsWith('<<<', i)) { endWord(state); i += 2; continue; }
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
endWord(state);
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) { state.heredocs.push(delimiter); i = delimiter.end - 1; continue; }
}
if (c === '\n' && state.heredocs.length) {
endWord(state);
let next = i + 1;
for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end;
state.heredocs.length = 0;
state.commandStart = true; i = next - 1; continue;
}
if (/[\s;|&()]/.test(c)) endWord(state);
else state.word += c;
const phase = state.cases[state.cases.length - 1];
// A case pattern's closing ')' is not the end of $(...). Only literal
// keyword positions affect this state; quoted or echo operands do not.
if (c === ')' && phase === 'pattern') {
state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; continue;
}
if (c === '(' && phase === 'pattern') continue;
if (c === ';' && input[i + 1] === ';' && phase === 'body') {
state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; i++; continue;
}
if (/[;|&\n]/.test(c)) state.commandStart = true;
if (c === '(') state.depth++;
if (c === ')') {
state.depth--;
if (state.depth === 0) {
stack.pop();
if (stack.length === 0) {
budget.spend(i - from + 1);
return { text: input.slice(from, i), end: i + 1 };
}
}
}
}
budget.spend(input.length - from + 1);
return { text: input.slice(from), end: input.length };
}
function hasExpansion(input, index, processSubstitution = false) {
return input[index] === '`' || (input[index] === '$' && input[index + 1] === '(') ||
(processSubstitution && '<>'.includes(input[index]) && input[index + 1] === '(');
}
// Unquoted heredoc bodies expand even inside quote characters in the body.
// Backslash still protects $, ` and backslash; quoted delimiters skip this pass.
function scanExpansions(input, budget) {
const nested = [];
for (let i = 0; i < input.length;) {
budget.spend();
if (input[i] === '\\' && /[$`\\\r\n]/.test(input[i + 1] || '')) {
const continued = continuationEnd(input, i);
i = continued !== i ? continued : i + 2;
continue;
}
if (hasExpansion(input, i)) {
const region = executionRegion(input, i, budget);
nested.push(region.text); i = region.end;
} else i++;
}
return nested;
}
function scanShell(input, budget) {
const commands = [];
const nested = [];
const pendingHeredocs = [];
const rootScope = { parent: null, isolated: false, conditional: false };
let scope = rootScope;
const command = pipeFrom => ({ words: [], redirects: [], pipeFrom, nested: [], scope });
let current = command(null);
let word = null;
let quote = null;
let pendingRedirect = null;
let i = 0;
function begin() {
if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true, dynamic: false };
}
function flushWord() {
if (!word) return;
word.end = i;
word.raw = input.slice(word.start, i);
if (pendingRedirect) {
const redirect = { operator: pendingRedirect, word, body: '' };
current.redirects.push(redirect);
if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push({ redirect, owner: current });
pendingRedirect = null;
} else current.words.push(word);
word = null;
}
function flushCommand(pipe = false, background = false) {
flushWord();
const previous = current;
previous.pipeTo = pipe;
previous.background = background;
if (previous.closedScope && (pipe || background)) {
previous.closedScope.isolated = true;
previous.closedScope.pipelineLast = false;
}
const first = previous.words[0];
if (first && !first.quoted && ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'].includes(first.value)) scope.conditional = true;
if (previous.words.length || previous.redirects.length) commands.push(previous);
current = command(pipe ? previous : null);
pendingRedirect = null;
}
function consumeHeredocs() {
for (const { redirect, owner } of pendingHeredocs) {
const region = heredocBody(input, i, redirect, budget);
redirect.body = region.body;
i = region.end;
if (!redirect.word.quoted) {
const regions = scanExpansions(redirect.body, budget);
for (const text of regions) { budget.spend(); nested.push(text); owner.nested.push(text); }
}
}
pendingHeredocs.length = 0;
}
while (i < input.length) {
budget.spend();
const c = input[i];
if (quote === "'" || quote === "$'") {
if (quote === "$'" && c === '\\' && i + 1 < input.length) {
const next = input[i + 1];
// Preserve boundaries without claiming general ANSI-C escape expansion.
word.value += next === "'" || next === '\\' ? next : c + next;
i += 2; continue;
}
if (c === "'") quote = null;
else word.value += c;
i++; continue;
}
const continued = continuationEnd(input, i);
if (continued !== i) { i = continued; continue; }
if (c === '\\') {
begin(); word.quoted = true;
const next = input[i + 1];
if (next === undefined) { word.value += c; i++; continue; }
if (quote === '"' && !'"$`\\'.includes(next)) word.value += '\\';
word.value += next; i += 2; continue;
}
if (hasExpansion(input, i, quote === null)) {
begin(); word.literal = false; word.dynamic = true;
const region = executionRegion(input, i, budget);
nested.push(region.text); current.nested.push(region.text); word.value += '\u0000'; i = region.end; continue;
}
// Parameter expansions remain opaque values. Escaped/single/ANSI-C
// quoted dollars have already been consumed as data above.
if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(input[i + 1] || '')) {
begin(); word.dynamic = true;
}
if (quote === '"') {
if (c === '"') quote = null;
else word.value += c;
i++; continue;
}
if (c === '$' && input[i + 1] === "'") {
// ANSI-C escaped quotes do not close the word; its contents never expand.
// Numeric/control escapes and generated names remain outside this grammar.
begin(); word.quoted = true; quote = "$'"; i += 2; continue;
}
if (c === '"' || c === "'") { begin(); word.quoted = true; quote = c; i++; continue; }
if (c === '#' && !word) {
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
continue;
}
const braceKeyword = (c === '{' || c === '}') && !word && current.words.length === 0 && /[\s;&|]/.test(input[i + 1] || ' ');
if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || braceKeyword) {
const pipe = c === '|' && input[i + 1] !== '|';
const background = c === '&' && input[i + 1] !== '&';
if ((c === '&' || c === '|') && input[i + 1] === c) scope.conditional = true;
const incomingPipe = Boolean(current.pipeFrom);
if (c === '(') {
flushWord();
// A function definition does not execute its body. Function grammar
// is unsupported: keep pre-definition alternatives instead of using
// flattened body mutations to certify a later command as safe.
if (current.words.length === 1 && !current.words[0].quoted &&
/^[A-Za-z_][A-Za-z0-9_]*$/.test(current.words[0].value)) scope.conditional = true;
}
flushCommand(pipe, background);
if (c === '(' || (braceKeyword && c === '{')) {
scope = { parent: scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe };
current.scope = scope;
} else if ((c === ')' || (braceKeyword && c === '}')) && scope.parent) {
const closedScope = scope;
scope = scope.parent;
current.scope = scope;
current.closedScope = closedScope;
// Ordered metadata only; there is no executable argv in this event.
commands.push({ ...command(null), scopeExit: closedScope });
}
i += (c === '&' || c === '|') && input[i + 1] === c ? 2 : 1;
if (c === '\n') consumeHeredocs();
continue;
}
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) {
if (word && /^\d+$/.test(word.value)) word = null;
flushWord();
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
current.redirects.push(redirect); pendingHeredocs.push({ redirect, owner: current });
i = delimiter.end; pendingRedirect = null; continue;
}
}
if (c === '<' || c === '>') {
// An immediately adjacent numeric word is a descriptor, not an argv word.
if (word && /^\d+$/.test(word.value)) word = null;
flushWord();
let operator = c;
if (input[i + 1] === c) operator += c;
if (operator === '<<' && input[i + 2] === '<') operator = '<<<';
else if (operator === '<<' && input[i + 2] === '-') operator = '<<-';
else if (input[i + 1] === '&') operator += '&';
pendingRedirect = operator; i += operator.length; continue;
}
if (/\s/.test(c)) { flushWord(); i++; continue; }
begin(); word.value += c; i++;
}
flushCommand();
return { commands, nested, rootScope };
}
module.exports = { createBudget, scanShell, scanExpansions };