mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-11 02:26:10 +00:00
pipelineSources() read the literal output of the commands that feed a shell, but a brace group or subshell producer has no words of its own, so nothing it printed reached the check. { echo 'git commit --no-verify -m x'; } | sh and (echo 'git push --no-verify'; printf x) | bash passed the guard, while echo 'git commit --no-verify -m x' | sh is blocked. shell-scan now keeps the source text of a closed scope, and pipelineSources() reads the commands in it that write to the group's output, as it already does for a plain producer.
543 lines
21 KiB
JavaScript
543 lines
21 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;
|
|
}
|
|
|
|
// Skip to the end of a comment. The returned index is the newline or EOF.
|
|
function skipComment(input, start, budget) {
|
|
let i = start;
|
|
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
|
|
return i;
|
|
}
|
|
|
|
function skipDelimiterBlanks(input, start, budget) {
|
|
let i = start;
|
|
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;
|
|
}
|
|
return i;
|
|
}
|
|
|
|
// A backslash in the delimiter word. Returns the index of the last consumed
|
|
// character; a continuation is removed without starting the word.
|
|
function delimiterEscape(input, i, word) {
|
|
const continued = continuationEnd(input, i);
|
|
if (continued !== i) return continued - 1;
|
|
word.began = true; word.quoted = true;
|
|
const next = input[i + 1];
|
|
if (next === undefined) { word.value += '\\'; return i; }
|
|
if (word.quote === '"' && !'"$`\\'.includes(next)) word.value += '\\';
|
|
word.value += next;
|
|
return i + 1;
|
|
}
|
|
|
|
function readDelimiterWord(input, start, budget) {
|
|
const word = { value: '', quote: null, began: false, quoted: false, end: start };
|
|
let i = start;
|
|
for (; i < input.length; i++) {
|
|
budget.spend();
|
|
const c = input[i];
|
|
if (word.quote === "'") {
|
|
if (c === "'") word.quote = null;
|
|
else word.value += c;
|
|
continue;
|
|
}
|
|
if (c === '\\') { i = delimiterEscape(input, i, word); continue; }
|
|
if (word.quote === '"') {
|
|
if (c === '"') word.quote = null;
|
|
else word.value += c;
|
|
continue;
|
|
}
|
|
if (c === "'" || c === '"') { word.quote = c; word.began = true; word.quoted = true; continue; }
|
|
if (/[\s;|&()<>]/.test(c) || (!word.began && c === '#')) break;
|
|
word.began = true; word.value += c;
|
|
}
|
|
word.end = i;
|
|
return word;
|
|
}
|
|
|
|
// 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] === '-' ? '<<-' : '<<';
|
|
const word = readDelimiterWord(input, skipDelimiterBlanks(input, start + operator.length, budget), budget);
|
|
return word.began ? { operator, word: { value: word.value, quoted: word.quoted }, end: word.end } : 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 };
|
|
}
|
|
|
|
// --- $(...) region state ---
|
|
// Each helper below takes the innermost frame and the index of the current
|
|
// character. Those that consume input return the index of the last consumed
|
|
// character, or undefined when the character was not theirs to handle.
|
|
|
|
function regionFrame() {
|
|
return { quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] };
|
|
}
|
|
|
|
function endRegionWord(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;
|
|
}
|
|
|
|
// Inside '...' or $'...': only the closing quote matters, and an ANSI-C escape
|
|
// protects the next character.
|
|
function regionQuotedEnd(state, input, i) {
|
|
const c = input[i];
|
|
if (state.quote === "$'" && c === '\\' && i + 1 < input.length) return i + 1;
|
|
if (c === "'") state.quote = null;
|
|
return i;
|
|
}
|
|
|
|
// Escapes and nested substitutions apply inside double quotes as well.
|
|
function regionSubstitution(state, stack, input, i, budget) {
|
|
const c = input[i];
|
|
if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; return i + 1; }
|
|
if (c === '$' && input[i + 1] === '(') {
|
|
state.word += '$()'; state.quotedWord = true; stack.push(regionFrame()); return i + 1;
|
|
}
|
|
if (c === '`') {
|
|
const region = legacyRegion(input, i, budget);
|
|
state.word += '`'; state.quotedWord = true; return region.end - 1;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function openRegionQuote(state, input, i) {
|
|
const c = input[i];
|
|
if (c === '$' && input[i + 1] === "'") {
|
|
state.quote = "$'"; state.word += "$'"; state.quotedWord = true; return i + 1;
|
|
}
|
|
if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; return i; }
|
|
return undefined;
|
|
}
|
|
|
|
// A here-string or heredoc operator ends the current word. A heredoc
|
|
// delimiter queues its body for the next newline.
|
|
function regionHeredocOperator(state, input, i, budget) {
|
|
if (input[i] !== '<') return undefined;
|
|
if (input.startsWith('<<<', i)) { endRegionWord(state); return i + 2; }
|
|
if (input[i + 1] === '<' && input[i + 2] !== '<') {
|
|
endRegionWord(state);
|
|
const delimiter = heredocDelimiter(input, i, budget);
|
|
if (delimiter) { state.heredocs.push(delimiter); return delimiter.end - 1; }
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function regionHeredocBodies(state, input, i, budget) {
|
|
endRegionWord(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;
|
|
return next - 1;
|
|
}
|
|
|
|
// A case pattern's closing ')' is not the end of $(...). Only literal
|
|
// keyword positions affect this state; quoted or echo operands do not.
|
|
function casePhaseStep(state, input, i) {
|
|
const c = input[i];
|
|
const phase = state.cases[state.cases.length - 1];
|
|
if (c === ')' && phase === 'pattern') {
|
|
state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; return i;
|
|
}
|
|
if (c === '(' && phase === 'pattern') return i;
|
|
if (c === ';' && input[i + 1] === ';' && phase === 'body') {
|
|
state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; return i + 1;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Tracks parenthesis depth; true once the outermost region has closed.
|
|
function closeRegionParen(stack, state, c) {
|
|
if (/[;|&\n]/.test(c)) state.commandStart = true;
|
|
if (c === '(') state.depth++;
|
|
if (c !== ')') return false;
|
|
state.depth--;
|
|
if (state.depth !== 0) return false;
|
|
stack.pop();
|
|
return stack.length === 0;
|
|
}
|
|
|
|
// 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 stack = [regionFrame()];
|
|
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 === "$'") { i = regionQuotedEnd(state, input, i); continue; }
|
|
const substituted = regionSubstitution(state, stack, input, i, budget);
|
|
if (substituted !== undefined) { i = substituted; continue; }
|
|
if (state.quote === '"') {
|
|
if (c === '"') state.quote = null;
|
|
continue;
|
|
}
|
|
const opened = openRegionQuote(state, input, i);
|
|
if (opened !== undefined) { i = opened; continue; }
|
|
if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) {
|
|
// Stop before the newline so pending heredoc bodies are still processed.
|
|
i = skipComment(input, i, budget) - 1;
|
|
continue;
|
|
}
|
|
const redirected = regionHeredocOperator(state, input, i, budget);
|
|
if (redirected !== undefined) { i = redirected; continue; }
|
|
if (c === '\n' && state.heredocs.length) { i = regionHeredocBodies(state, input, i, budget); continue; }
|
|
if (/[\s;|&()]/.test(c)) endRegionWord(state);
|
|
else state.word += c;
|
|
const stepped = casePhaseStep(state, input, i);
|
|
if (stepped !== undefined) { i = stepped; continue; }
|
|
if (closeRegionParen(stack, state, c)) {
|
|
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;
|
|
}
|
|
|
|
// --- Top-level scan state ---
|
|
// scan.i is the read position; every helper advances it past what it consumed.
|
|
|
|
const COMPOUND_KEYWORDS = ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'];
|
|
|
|
function newCommand(scan, pipeFrom) {
|
|
return { words: [], redirects: [], pipeFrom, nested: [], scope: scan.scope };
|
|
}
|
|
|
|
function createScan(input, budget) {
|
|
const rootScope = { parent: null, isolated: false, conditional: false };
|
|
const scan = {
|
|
input, budget, commands: [], nested: [], pendingHeredocs: [], rootScope,
|
|
scope: rootScope, current: null, word: null, quote: null, pendingRedirect: null, i: 0
|
|
};
|
|
scan.current = newCommand(scan, null);
|
|
return scan;
|
|
}
|
|
|
|
function beginWord(scan) {
|
|
if (!scan.word) scan.word = { value: '', start: scan.i, end: scan.i, quoted: false, literal: true, dynamic: false };
|
|
}
|
|
|
|
function flushWord(scan) {
|
|
const { word, current } = scan;
|
|
if (!word) return;
|
|
word.end = scan.i;
|
|
word.raw = scan.input.slice(word.start, scan.i);
|
|
if (scan.pendingRedirect) {
|
|
const redirect = { operator: scan.pendingRedirect, word, body: '' };
|
|
current.redirects.push(redirect);
|
|
if (scan.pendingRedirect === '<<' || scan.pendingRedirect === '<<-') scan.pendingHeredocs.push({ redirect, owner: current });
|
|
scan.pendingRedirect = null;
|
|
} else current.words.push(word);
|
|
scan.word = null;
|
|
}
|
|
|
|
function flushCommand(scan, pipe = false, background = false) {
|
|
flushWord(scan);
|
|
const previous = scan.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 && COMPOUND_KEYWORDS.includes(first.value)) scan.scope.conditional = true;
|
|
if (previous.words.length || previous.redirects.length) scan.commands.push(previous);
|
|
scan.current = newCommand(scan, pipe ? previous : null);
|
|
scan.pendingRedirect = null;
|
|
}
|
|
|
|
function consumeHeredocs(scan) {
|
|
for (const { redirect, owner } of scan.pendingHeredocs) {
|
|
const region = heredocBody(scan.input, scan.i, redirect, scan.budget);
|
|
redirect.body = region.body;
|
|
scan.i = region.end;
|
|
if (!redirect.word.quoted) {
|
|
const regions = scanExpansions(redirect.body, scan.budget);
|
|
for (const text of regions) { scan.budget.spend(); scan.nested.push(text); owner.nested.push(text); }
|
|
}
|
|
}
|
|
scan.pendingHeredocs.length = 0;
|
|
}
|
|
|
|
// Inside '...' or $'...'.
|
|
function scanQuotedChar(scan) {
|
|
const c = scan.input[scan.i];
|
|
if (scan.quote === "$'" && c === '\\' && scan.i + 1 < scan.input.length) {
|
|
const next = scan.input[scan.i + 1];
|
|
// Preserve boundaries without claiming general ANSI-C escape expansion.
|
|
scan.word.value += next === "'" || next === '\\' ? next : c + next;
|
|
scan.i += 2;
|
|
return;
|
|
}
|
|
if (c === "'") scan.quote = null;
|
|
else scan.word.value += c;
|
|
scan.i++;
|
|
}
|
|
|
|
function scanDoubleQuotedChar(scan, c) {
|
|
if (c === '"') scan.quote = null;
|
|
else scan.word.value += c;
|
|
scan.i++;
|
|
}
|
|
|
|
function scanEscape(scan) {
|
|
beginWord(scan); scan.word.quoted = true;
|
|
const next = scan.input[scan.i + 1];
|
|
if (next === undefined) { scan.word.value += '\\'; scan.i++; return; }
|
|
if (scan.quote === '"' && !'"$`\\'.includes(next)) scan.word.value += '\\';
|
|
scan.word.value += next; scan.i += 2;
|
|
}
|
|
|
|
function scanSubstitution(scan) {
|
|
beginWord(scan); scan.word.literal = false; scan.word.dynamic = true;
|
|
const region = executionRegion(scan.input, scan.i, scan.budget);
|
|
scan.nested.push(region.text); scan.current.nested.push(region.text);
|
|
scan.word.value += '\u0000'; scan.i = region.end;
|
|
}
|
|
|
|
// Parameter expansions remain opaque values. Escaped/single/ANSI-C
|
|
// quoted dollars have already been consumed as data before this point.
|
|
function markParameterExpansion(scan, c) {
|
|
if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(scan.input[scan.i + 1] || '')) {
|
|
beginWord(scan); scan.word.dynamic = true;
|
|
}
|
|
}
|
|
|
|
function openQuote(scan, c) {
|
|
if (c === '$' && scan.input[scan.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.
|
|
beginWord(scan); scan.word.quoted = true; scan.quote = "$'"; scan.i += 2;
|
|
return true;
|
|
}
|
|
if (c === '"' || c === "'") { beginWord(scan); scan.word.quoted = true; scan.quote = c; scan.i++; return true; }
|
|
return false;
|
|
}
|
|
|
|
function isBraceKeyword(scan, c) {
|
|
return (c === '{' || c === '}') && !scan.word && scan.current.words.length === 0 && /[\s;&|]/.test(scan.input[scan.i + 1] || ' ');
|
|
}
|
|
|
|
function markFunctionDefinition(scan) {
|
|
flushWord(scan);
|
|
// 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.
|
|
const words = scan.current.words;
|
|
if (words.length === 1 && !words[0].quoted && /^[A-Za-z_][A-Za-z0-9_]*$/.test(words[0].value)) scan.scope.conditional = true;
|
|
}
|
|
|
|
function openScope(scan, c, incomingPipe) {
|
|
scan.scope = { parent: scan.scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe, bodyStart: scan.i + 1 };
|
|
scan.current.scope = scan.scope;
|
|
}
|
|
|
|
function closeScope(scan) {
|
|
const closedScope = scan.scope;
|
|
// The source text between the braces or parentheses, for a reader of the
|
|
// group's output.
|
|
closedScope.body = scan.input.slice(closedScope.bodyStart, scan.i);
|
|
scan.scope = closedScope.parent;
|
|
scan.current.scope = scan.scope;
|
|
scan.current.closedScope = closedScope;
|
|
// Ordered metadata only; there is no executable argv in this event.
|
|
scan.commands.push({ ...newCommand(scan, null), scopeExit: closedScope });
|
|
}
|
|
|
|
function scanOperator(scan, c, braceKeyword) {
|
|
const doubled = (c === '&' || c === '|') && scan.input[scan.i + 1] === c;
|
|
const pipe = c === '|' && !doubled;
|
|
const background = c === '&' && !doubled;
|
|
if (doubled) scan.scope.conditional = true;
|
|
const incomingPipe = Boolean(scan.current.pipeFrom);
|
|
if (c === '(') markFunctionDefinition(scan);
|
|
flushCommand(scan, pipe, background);
|
|
if (c === '(' || (braceKeyword && c === '{')) openScope(scan, c, incomingPipe);
|
|
else if ((c === ')' || (braceKeyword && c === '}')) && scan.scope.parent) closeScope(scan);
|
|
scan.i += doubled ? 2 : 1;
|
|
if (c === '\n') consumeHeredocs(scan);
|
|
}
|
|
|
|
// An immediately adjacent numeric word is a descriptor, not an argv word.
|
|
function dropDescriptorWord(scan) {
|
|
if (scan.word && /^\d+$/.test(scan.word.value)) scan.word = null;
|
|
}
|
|
|
|
function queueHeredoc(scan, delimiter) {
|
|
dropDescriptorWord(scan);
|
|
flushWord(scan);
|
|
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
|
|
scan.current.redirects.push(redirect);
|
|
scan.pendingHeredocs.push({ redirect, owner: scan.current });
|
|
scan.i = delimiter.end;
|
|
scan.pendingRedirect = null;
|
|
}
|
|
|
|
function redirectOperator(input, i, c) {
|
|
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 += '&';
|
|
return operator;
|
|
}
|
|
|
|
function scanRedirect(scan, c) {
|
|
const { input } = scan;
|
|
if (c === '<' && input[scan.i + 1] === '<' && input[scan.i + 2] !== '<') {
|
|
const delimiter = heredocDelimiter(input, scan.i, scan.budget);
|
|
if (delimiter) { queueHeredoc(scan, delimiter); return; }
|
|
}
|
|
dropDescriptorWord(scan);
|
|
flushWord(scan);
|
|
scan.pendingRedirect = redirectOperator(input, scan.i, c);
|
|
scan.i += scan.pendingRedirect.length;
|
|
}
|
|
|
|
function scanShell(input, budget) {
|
|
const scan = createScan(input, budget);
|
|
while (scan.i < input.length) {
|
|
budget.spend();
|
|
if (scan.quote === "'" || scan.quote === "$'") { scanQuotedChar(scan); continue; }
|
|
const continued = continuationEnd(input, scan.i);
|
|
if (continued !== scan.i) { scan.i = continued; continue; }
|
|
const c = input[scan.i];
|
|
if (c === '\\') { scanEscape(scan); continue; }
|
|
if (hasExpansion(input, scan.i, scan.quote === null)) { scanSubstitution(scan); continue; }
|
|
markParameterExpansion(scan, c);
|
|
if (scan.quote === '"') { scanDoubleQuotedChar(scan, c); continue; }
|
|
if (openQuote(scan, c)) continue;
|
|
if (c === '#' && !scan.word) { scan.i = skipComment(input, scan.i, budget); continue; }
|
|
const braceKeyword = isBraceKeyword(scan, c);
|
|
if (braceKeyword || '\n;&|()'.includes(c)) { scanOperator(scan, c, braceKeyword); continue; }
|
|
if (c === '<' || c === '>') { scanRedirect(scan, c); continue; }
|
|
if (/\s/.test(c)) { flushWord(scan); scan.i++; continue; }
|
|
beginWord(scan); scan.word.value += c; scan.i++;
|
|
}
|
|
flushCommand(scan);
|
|
return { commands: scan.commands, nested: scan.nested, rootScope: scan.rootScope };
|
|
}
|
|
|
|
module.exports = { createBudget, scanShell, scanExpansions };
|