mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-03 06:34:16 +08:00
refactor: keep heredoc scanning linear
This commit is contained in:
@@ -124,30 +124,6 @@ function findHeredocs(line) {
|
||||
return heredocs.includes(null) ? null : heredocs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over executable substitutions in an unquoted heredoc.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Generator<string>}
|
||||
*/
|
||||
function* iterateHeredocCommandSubstitutions(text) {
|
||||
let escaped = false;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '`' || (ch === '$' && text[i + 1] === '(')) {
|
||||
yield* extractCommandSubstitutions(text.slice(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract executable substitutions from an unquoted heredoc. Quote characters
|
||||
* in its payload are literal and do not suppress expansion.
|
||||
@@ -157,7 +133,58 @@ function* iterateHeredocCommandSubstitutions(text) {
|
||||
*/
|
||||
function extractHeredocCommandSubstitutions(body) {
|
||||
const text = body.join('\n');
|
||||
return [...new Set(iterateHeredocCommandSubstitutions(text))];
|
||||
return [...new Set(extractCommandSubstitutions(text, { literalOuterQuotes: true }))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one heredoc body and return its immutable parser result.
|
||||
*
|
||||
* @param {string[]} lines
|
||||
* @param {number} startIndex
|
||||
* @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }} heredoc
|
||||
* @returns {{ nextIndex: number, substitutions: string[] } | null}
|
||||
*/
|
||||
function consumeHeredocBody(lines, startIndex, heredoc) {
|
||||
for (let lineIndex = startIndex; lineIndex < lines.length; lineIndex += 1) {
|
||||
const line = lines[lineIndex];
|
||||
if (!heredoc.quoted && /\\$/.test(line)) return null;
|
||||
const delimiterLine = heredoc.stripTabs ? line.replace(/^\t+/, '') : line;
|
||||
if (delimiterLine !== heredoc.delimiter) continue;
|
||||
const body = lines.slice(startIndex, lineIndex);
|
||||
const substitutions = heredoc.quoted ? [] : extractHeredocCommandSubstitutions(body);
|
||||
return { nextIndex: lineIndex + 1, substitutions };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} lines
|
||||
* @param {number} startIndex
|
||||
* @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }[]} heredocs
|
||||
* @returns {{ nextIndex: number, chunks: object | null } | null}
|
||||
*/
|
||||
function consumeHeredocBodies(lines, startIndex, heredocs) {
|
||||
let state = { nextIndex: startIndex, chunks: null };
|
||||
for (const heredoc of heredocs) {
|
||||
const consumed = consumeHeredocBody(lines, state.nextIndex, heredoc);
|
||||
if (!consumed) return null;
|
||||
state = {
|
||||
nextIndex: consumed.nextIndex,
|
||||
chunks: consumed.substitutions.length === 0 ? state.chunks : { substitutions: consumed.substitutions, previous: state.chunks }
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** @returns {Generator<string>} */
|
||||
function* iterateSubstitutionChunks(chunks) {
|
||||
let ordered = null;
|
||||
for (let chunk = chunks; chunk; chunk = chunk.previous) {
|
||||
ordered = { substitutions: chunk.substitutions, next: ordered };
|
||||
}
|
||||
for (let chunk = ordered; chunk; chunk = chunk.next) {
|
||||
yield* chunk.substitutions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,62 +201,26 @@ function extractHeredocCommandSubstitutions(body) {
|
||||
function stripHeredocBodies(input) {
|
||||
const raw = String(input || '');
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let pending = [];
|
||||
let pendingIndex = 0;
|
||||
let bodyStartIndex = -1;
|
||||
let headerIndex = -1;
|
||||
let trailingStartIndex = lines.length;
|
||||
let substitutionText = '';
|
||||
let substitutionCount = 0;
|
||||
let completedHeredoc = false;
|
||||
let pending = [];
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||
const line = lines[lineIndex];
|
||||
if (pendingIndex < pending.length) {
|
||||
const current = pending[pendingIndex];
|
||||
if (!current.quoted && /\\$/.test(line)) return raw;
|
||||
const delimiterLine = current.stripTabs ? line.replace(/^\t+/, '') : line;
|
||||
if (delimiterLine === current.delimiter) {
|
||||
if (!current.quoted) {
|
||||
for (const substitution of extractHeredocCommandSubstitutions(lines.slice(bodyStartIndex, lineIndex))) {
|
||||
substitutionText = substitutionCount === 0 ? substitution : `${substitutionText}\n${substitution}`;
|
||||
substitutionCount += 1;
|
||||
}
|
||||
}
|
||||
pendingIndex += 1;
|
||||
bodyStartIndex = lineIndex + 1;
|
||||
if (pendingIndex === pending.length) {
|
||||
completedHeredoc = true;
|
||||
trailingStartIndex = lineIndex + 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (completedHeredoc && line.trim()) return raw;
|
||||
if (completedHeredoc) continue;
|
||||
const heredocs = findHeredocs(line);
|
||||
if (heredocs === null) return raw;
|
||||
if (heredocs.length > 0 && !isProvenPassiveHeredocLine(line)) return raw;
|
||||
if (heredocs.length > 0) {
|
||||
pending = heredocs;
|
||||
pendingIndex = 0;
|
||||
bodyStartIndex = lineIndex + 1;
|
||||
headerIndex = lineIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pendingIndex < pending.length) return raw;
|
||||
if (headerIndex < 0) return lines.join('\n');
|
||||
|
||||
const prefix = lines.slice(0, headerIndex + 1).join('\n');
|
||||
const trailingCount = lines.length - trailingStartIndex;
|
||||
const trailing = lines.slice(trailingStartIndex).join('\n');
|
||||
let result = prefix;
|
||||
let resultCount = headerIndex + 1;
|
||||
if (substitutionCount > 0) {
|
||||
result = resultCount === 0 ? substitutionText : `${result}\n${substitutionText}`;
|
||||
resultCount += substitutionCount;
|
||||
}
|
||||
if (trailingCount > 0) result = resultCount === 0 ? trailing : `${result}\n${trailing}`;
|
||||
return result;
|
||||
const consumed = consumeHeredocBodies(lines, headerIndex + 1, pending);
|
||||
if (!consumed) return raw;
|
||||
const trailing = lines.slice(consumed.nextIndex);
|
||||
if (trailing.some(line => line.trim())) return raw;
|
||||
const substitutions = iterateSubstitutionChunks(consumed.chunks);
|
||||
return [...lines.slice(0, headerIndex + 1), ...substitutions, ...trailing].join('\n');
|
||||
}
|
||||
|
||||
module.exports = { stripHeredocBodies };
|
||||
|
||||
@@ -1,126 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Extract executable command-substitution bodies from a shell line.
|
||||
*
|
||||
* Single quotes are literal, so substitutions inside them are ignored;
|
||||
* double quotes still permit substitutions, so those bodies are scanned
|
||||
* before quoted text is stripped. Returns each substitution body plus
|
||||
* any nested substitutions discovered recursively.
|
||||
*
|
||||
* Originally introduced in scripts/hooks/gateguard-fact-force.js
|
||||
* (PR #1853 round 2). Extracted to a shared lib so other PreToolUse
|
||||
* hooks that need the same "scan inside `$(...)` and backticks"
|
||||
* behavior can reuse it without duplicating the parser.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function extractCommandSubstitutions(input) {
|
||||
const source = String(input || '');
|
||||
const substitutions = [];
|
||||
/** @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 };
|
||||
}
|
||||
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
/**
|
||||
* 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];
|
||||
const prev = source[i - 1];
|
||||
|
||||
if (ch === '\\' && !inSingle) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDouble && prev !== '\\') {
|
||||
if (!literalOuterQuotes && ch === "'" && !inDouble) {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingle && prev !== '\\') {
|
||||
if (!literalOuterQuotes && ch === '"' && !inSingle) {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '`') {
|
||||
let body = '';
|
||||
i += 1;
|
||||
while (i < source.length) {
|
||||
const inner = source[i];
|
||||
if (inner === '\\') {
|
||||
body += inner;
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
// Trailing backslash at end of an unterminated span: advance past
|
||||
// it so it is not appended a second time by the fallthrough below.
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === '`') {
|
||||
break;
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
if (body.trim()) {
|
||||
substitutions.push(body);
|
||||
substitutions.push(...extractCommandSubstitutions(body));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '$' && source[i + 1] === '(') {
|
||||
let depth = 1;
|
||||
let body = '';
|
||||
let bodyInSingle = false;
|
||||
let bodyInDouble = false;
|
||||
i += 2;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !bodyInSingle) {
|
||||
body += inner;
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
// Trailing backslash at end of an unterminated span: advance past
|
||||
// it so it is not appended a second time by the fallthrough below.
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
|
||||
bodyInSingle = !bodyInSingle;
|
||||
} else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') {
|
||||
bodyInDouble = !bodyInDouble;
|
||||
} else if (!bodyInSingle && !bodyInDouble) {
|
||||
if (inner === '(') {
|
||||
depth += 1;
|
||||
} else if (inner === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
if (body.trim()) {
|
||||
substitutions.push(body);
|
||||
substitutions.push(...extractCommandSubstitutions(body));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return substitutions;
|
||||
/**
|
||||
* 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)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user