mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 13:05:18 +02:00
Preserve original contributor history and all current-main assertions while bounding quote, heredoc, shell-option and deferred-scan handling. Source-PR: https://github.com/affaan-m/ECC/pull/2965 Source-Parent:5a878131deMain-Parent:d3b8a3e908Review-Manifest-SHA256: 5d23f47e60516b66733805aec0ebcd47f19265c964529fa0b1220c8aa6903add
356 lines
14 KiB
JavaScript
356 lines
14 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 === "'") {
|
|
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 === '"' || 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 = [];
|
|
let current = { words: [], redirects: [], pipeFrom: 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 };
|
|
}
|
|
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);
|
|
pendingRedirect = null;
|
|
} else current.words.push(word);
|
|
word = null;
|
|
}
|
|
function flushCommand(pipe = false) {
|
|
flushWord();
|
|
const previous = current;
|
|
if (previous.words.length || previous.redirects.length) commands.push(previous);
|
|
current = { words: [], redirects: [], pipeFrom: pipe ? previous : null };
|
|
pendingRedirect = null;
|
|
}
|
|
function consumeHeredocs() {
|
|
for (const redirect of pendingHeredocs) {
|
|
const region = heredocBody(input, i, redirect, budget);
|
|
redirect.body = region.body;
|
|
i = region.end;
|
|
if (!redirect.word.quoted) nested.push(...scanExpansions(redirect.body, budget));
|
|
}
|
|
pendingHeredocs.length = 0;
|
|
}
|
|
while (i < input.length) {
|
|
budget.spend();
|
|
const c = input[i];
|
|
if (quote === "'") {
|
|
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;
|
|
const region = executionRegion(input, i, budget);
|
|
nested.push(region.text); word.value += '\u0000'; i = region.end; continue;
|
|
}
|
|
if (quote === '"') {
|
|
if (c === '"') quote = null;
|
|
else word.value += c;
|
|
i++; continue;
|
|
}
|
|
if (c === '$' && input[i + 1] === "'") {
|
|
// Literal ANSI-C words without escape interpretation; escaped/generated
|
|
// names are not claimed to be a complete expansion implementation.
|
|
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] !== '|';
|
|
flushCommand(pipe);
|
|
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);
|
|
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 };
|
|
}
|
|
|
|
module.exports = { createBudget, scanShell, scanExpansions };
|