mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-20 15:01:46 +02:00
extractSubshellGroups and extractBraceGroups each carried their own copies of the quote-aware skipping for $(...), backtick and (...) spans, which is where the complexity and nesting warnings came from. The skipping now lives in findParenSpanEnd, findBacktickEnd and skipOuterSpan, with readSubshellBody and readBraceBody doing the body collection. Behavior is unchanged: the same bodies come back in the same order, trailing backslashes and unterminated spans resolve the same way, and the three exports keep their signatures.
357 lines
12 KiB
JavaScript
357 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
/** @returns {{ body: string, endIndex: number }} */
|
|
function readBacktickSubstitution(source, startIndex) {
|
|
let body = '';
|
|
let endIndex = startIndex + 1;
|
|
while (endIndex < source.length) {
|
|
const inner = source[endIndex];
|
|
if (inner === '\\') {
|
|
const escaped = source[endIndex + 1];
|
|
body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`;
|
|
endIndex += escaped === undefined ? 1 : 2;
|
|
continue;
|
|
}
|
|
if (inner === '`') break;
|
|
body = `${body}${inner}`;
|
|
endIndex += 1;
|
|
}
|
|
return { body, endIndex };
|
|
}
|
|
|
|
/** @returns {{ body: string, endIndex: number }} */
|
|
function readDollarSubstitution(source, startIndex) {
|
|
let body = '';
|
|
let depth = 1;
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let endIndex = startIndex + 2;
|
|
while (endIndex < source.length && depth > 0) {
|
|
const inner = source[endIndex];
|
|
if (inner === '\\' && !inSingle) {
|
|
const escaped = source[endIndex + 1];
|
|
body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`;
|
|
endIndex += escaped === undefined ? 1 : 2;
|
|
continue;
|
|
}
|
|
if (inner === "'" && !inDouble) inSingle = !inSingle;
|
|
else if (inner === '"' && !inSingle) inDouble = !inDouble;
|
|
else if (!inSingle && !inDouble && inner === '(') depth += 1;
|
|
else if (!inSingle && !inDouble && inner === ')') depth -= 1;
|
|
if (depth > 0) body = `${body}${inner}`;
|
|
endIndex += depth > 0 ? 1 : 0;
|
|
}
|
|
return { body, endIndex };
|
|
}
|
|
|
|
/**
|
|
* Iterate over command-substitution bodies, followed by nested bodies.
|
|
* Quote characters in an unquoted heredoc are literal only at the outer level;
|
|
* substitutions still use normal shell quote semantics internally.
|
|
*
|
|
* @param {string} input
|
|
* @param {{ literalOuterQuotes?: boolean }} [options]
|
|
* @returns {Generator<string>}
|
|
*/
|
|
function* iterateCommandSubstitutions(input, options = {}) {
|
|
const source = String(input || '');
|
|
const literalOuterQuotes = options.literalOuterQuotes === true;
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
for (let i = 0; i < source.length; i += 1) {
|
|
const ch = source[i];
|
|
if (ch === '\\' && !inSingle) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (!literalOuterQuotes && ch === "'" && !inDouble) {
|
|
inSingle = !inSingle;
|
|
continue;
|
|
}
|
|
if (!literalOuterQuotes && ch === '"' && !inSingle) {
|
|
inDouble = !inDouble;
|
|
continue;
|
|
}
|
|
if (inSingle) continue;
|
|
const span = ch === '`' ? readBacktickSubstitution(source, i) : null;
|
|
const substitution = ch === '$' && source[i + 1] === '(' ? readDollarSubstitution(source, i) : span;
|
|
if (!substitution) continue;
|
|
i = substitution.endIndex;
|
|
if (!substitution.body.trim()) continue;
|
|
yield substitution.body;
|
|
yield* iterateCommandSubstitutions(substitution.body);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract executable command-substitution bodies from a shell line.
|
|
*
|
|
* @param {string} input
|
|
* @param {{ literalOuterQuotes?: boolean }} [options]
|
|
* @returns {string[]}
|
|
*/
|
|
function extractCommandSubstitutions(input, options = {}) {
|
|
return [...iterateCommandSubstitutions(input, options)];
|
|
}
|
|
|
|
// --- Shared span readers ------------------------------------------------------
|
|
// The group extractors below all need the same quote-aware skipping of
|
|
// `$(...)`, backtick and `(...)` spans. Each reader takes the index of the
|
|
// first character inside the span and returns where scanning should resume.
|
|
|
|
function parenDelta(ch) {
|
|
if (ch === '(') return 1;
|
|
if (ch === ')') return -1;
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Toggle quote state for a quote character at the current position.
|
|
* Returns true when `ch` opened or closed a quote.
|
|
*/
|
|
function toggleQuote(ch, prev, quotes) {
|
|
if (ch === "'" && !quotes.inDouble && prev !== '\\') {
|
|
quotes.inSingle = !quotes.inSingle;
|
|
return true;
|
|
}
|
|
if (ch === '"' && !quotes.inSingle && prev !== '\\') {
|
|
quotes.inDouble = !quotes.inDouble;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Index just past the `)` that closes a paren span whose first inner character
|
|
* is at `start`, honoring quotes and escapes inside the span.
|
|
*/
|
|
function findParenSpanEnd(source, start) {
|
|
const quotes = { inSingle: false, inDouble: false };
|
|
let depth = 1;
|
|
let i = start;
|
|
while (i < source.length && depth > 0) {
|
|
const inner = source[i];
|
|
if (inner === '\\' && !quotes.inSingle) {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) {
|
|
depth += parenDelta(inner);
|
|
}
|
|
i += 1;
|
|
}
|
|
return i;
|
|
}
|
|
|
|
/**
|
|
* Index of the backtick that closes a span whose first inner character is at
|
|
* `start`, or `source.length` when the span is unterminated.
|
|
*/
|
|
function findBacktickEnd(source, start) {
|
|
let i = start;
|
|
while (i < source.length && source[i] !== '`') {
|
|
if (source[i] === '\\' && i + 1 < source.length) {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
i += 1;
|
|
}
|
|
return i;
|
|
}
|
|
|
|
/**
|
|
* Top-level scan step shared by the group extractors: escapes, quote state,
|
|
* `$(...)` and backtick spans. Returns the last index consumed so a `for`
|
|
* loop's `i++` resumes after it, or -1 when the caller must inspect `source[i]`.
|
|
*/
|
|
function skipOuterSpan(source, i, quotes) {
|
|
const ch = source[i];
|
|
if (ch === '\\' && !quotes.inSingle) return i + 1;
|
|
if (toggleQuote(ch, source[i - 1], quotes)) return i;
|
|
if (quotes.inSingle || quotes.inDouble) return i;
|
|
if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2) - 1;
|
|
if (ch === '`') return findBacktickEnd(source, i + 1);
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Read a `(...)` subshell body starting just inside the opening paren. Returns
|
|
* the body text and the index of the closing paren (or where input ran out).
|
|
*/
|
|
function readSubshellBody(source, start) {
|
|
const quotes = { inSingle: false, inDouble: false };
|
|
let depth = 1;
|
|
let body = '';
|
|
let i = start;
|
|
while (i < source.length && depth > 0) {
|
|
const inner = source[i];
|
|
if (inner === '\\' && !quotes.inSingle) {
|
|
// Keep the escape and its operand together; a trailing backslash at the
|
|
// end of an unterminated span is appended once and the scan stops.
|
|
body += source.slice(i, i + 2);
|
|
i = Math.min(i + 2, source.length);
|
|
continue;
|
|
}
|
|
if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) {
|
|
depth += parenDelta(inner);
|
|
if (depth === 0) break;
|
|
}
|
|
body += inner;
|
|
i += 1;
|
|
}
|
|
return { body, end: i };
|
|
}
|
|
|
|
/**
|
|
* Extract bodies of plain `(...)` subshell groups.
|
|
*
|
|
* Bash treats `(npm run dev)` as a subshell that executes its contents, but
|
|
* the regex-light segment splitters used by our PreToolUse hooks don't peer
|
|
* inside those parens. This helper finds top-level `(...)` groups (skipping
|
|
* `$(...)` command substitutions and backticks, which `extractCommandSubstitutions`
|
|
* already covers) and returns each body, recursing for nested groups.
|
|
*
|
|
* Quote semantics:
|
|
* - Single quotes are literal: `'( ... )'` is a string, not a subshell.
|
|
* - Double quotes are literal *for parens*: `"( ... )"` is a string too —
|
|
* bash only honors `$( )` inside double quotes, not bare `( )`.
|
|
*
|
|
* @param {string} input
|
|
* @returns {string[]}
|
|
*/
|
|
function extractSubshellGroups(input) {
|
|
const source = String(input || '');
|
|
const groups = [];
|
|
const quotes = { inSingle: false, inDouble: false };
|
|
|
|
for (let i = 0; i < source.length; i++) {
|
|
const skipped = skipOuterSpan(source, i, quotes);
|
|
if (skipped !== -1) {
|
|
i = skipped;
|
|
continue;
|
|
}
|
|
if (source[i] !== '(') continue;
|
|
const { body, end } = readSubshellBody(source, i + 1);
|
|
i = end;
|
|
if (body.trim()) {
|
|
groups.push(body);
|
|
groups.push(...extractSubshellGroups(body));
|
|
}
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* Inside a brace body, `$(...)`, backtick and `(...)` spans are copied through
|
|
* verbatim so a `}` inside them cannot close the group. Returns the index just
|
|
* past the span, or -1 when `source[i]` does not open one.
|
|
*/
|
|
function findNestedSpanEnd(source, i) {
|
|
const ch = source[i];
|
|
if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2);
|
|
if (ch === '`') return Math.min(findBacktickEnd(source, i + 1) + 1, source.length);
|
|
if (ch === '(') return findParenSpanEnd(source, i + 1);
|
|
return -1;
|
|
}
|
|
|
|
// `{` is a reserved word only when followed by whitespace and preceded by the
|
|
// line start, whitespace, or a shell operator (`;`, `|`, `&`, `(`).
|
|
function opensBraceGroup(source, i) {
|
|
if (source[i] !== '{' || !/\s/.test(source[i + 1] || '')) return false;
|
|
return i === 0 || /[\s;|&(]/.test(source[i - 1]);
|
|
}
|
|
|
|
/**
|
|
* Read a `{ ...; }` brace body starting just after the opening brace. Returns
|
|
* the body text and the index of the closing brace (or where input ran out).
|
|
*/
|
|
function readBraceBody(source, start) {
|
|
const quotes = { inSingle: false, inDouble: false };
|
|
let depth = 1;
|
|
let body = '';
|
|
let i = start;
|
|
while (i < source.length && depth > 0) {
|
|
const inner = source[i];
|
|
const prev = source[i - 1];
|
|
if (inner === '\\' && !quotes.inSingle) {
|
|
body += source.slice(i, i + 2);
|
|
i = Math.min(i + 2, source.length);
|
|
continue;
|
|
}
|
|
if (toggleQuote(inner, prev, quotes) || quotes.inSingle || quotes.inDouble) {
|
|
body += inner;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
const spanEnd = findNestedSpanEnd(source, i);
|
|
if (spanEnd !== -1) {
|
|
body += source.slice(i, spanEnd);
|
|
i = spanEnd;
|
|
continue;
|
|
}
|
|
if (inner === '{' && /\s/.test(source[i + 1] || '')) {
|
|
// Match the outer-scan boundary rule for nested `{` so tokens like
|
|
// `foo{` (no boundary, but followed by space via `foo{ bar`) cannot
|
|
// bump nested depth.
|
|
if (/[\s;|&(]/.test(prev)) depth += 1;
|
|
} else if (inner === '}' && (prev === ';' || /\s/.test(prev))) {
|
|
depth -= 1;
|
|
if (depth === 0) break;
|
|
}
|
|
body += inner;
|
|
i += 1;
|
|
}
|
|
return { body, end: i };
|
|
}
|
|
|
|
/**
|
|
* Extract bodies of `{ ...; }` brace groups.
|
|
*
|
|
* Bash brace groups run their body in the *current* shell (unlike `(...)`,
|
|
* which forks a subshell). Both forms group multiple commands, so for the
|
|
* purposes of destructive-bash and dev-server detection they are equivalent:
|
|
* a `rm -rf` or `npm run dev` inside `{ ...; }` still executes.
|
|
*
|
|
* Recognition rules match bash's own reserved-word semantics:
|
|
* - `{` is a reserved word only when followed by whitespace and preceded by
|
|
* the line start, whitespace, or a shell operator (`;`, `|`, `&`, `(`).
|
|
* So `{npm run dev}` is NOT a brace group (single token starting with `{`).
|
|
* - `}` closes the group only when preceded by `;` or whitespace.
|
|
* So `foo}` inside the body is not a closing brace.
|
|
* - Single quotes are literal; double quotes are also literal for `{`/`}`.
|
|
* - `$(...)`, backticks, and plain `(...)` spans are skipped so we don't
|
|
* double-extract bodies the sibling extractors already cover.
|
|
*
|
|
* @param {string} input
|
|
* @returns {string[]}
|
|
*/
|
|
function extractBraceGroups(input) {
|
|
const source = String(input || '');
|
|
const groups = [];
|
|
const quotes = { inSingle: false, inDouble: false };
|
|
|
|
for (let i = 0; i < source.length; i++) {
|
|
const skipped = skipOuterSpan(source, i, quotes);
|
|
if (skipped !== -1) {
|
|
i = skipped;
|
|
continue;
|
|
}
|
|
if (source[i] === '(') {
|
|
i = findParenSpanEnd(source, i + 1) - 1;
|
|
continue;
|
|
}
|
|
if (!opensBraceGroup(source, i)) continue;
|
|
const { body, end } = readBraceBody(source, i + 1);
|
|
i = end;
|
|
if (body.trim()) {
|
|
groups.push(body);
|
|
groups.push(...extractBraceGroups(body));
|
|
}
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
module.exports = { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups };
|