fix: normalize heredoc line continuations

This commit is contained in:
dajiaohuang
2026-08-29 14:55:14 -04:00
committed by haelyra
parent 9768c075c3
commit c40d0e4f7c
2 changed files with 73 additions and 9 deletions
+40 -9
View File
@@ -124,6 +124,35 @@ function findHeredocs(line) {
return heredocs.includes(null) ? null : heredocs;
}
/** @returns {boolean} */
function hasLineContinuation(line) {
const trailing = line.match(/\\+$/);
return Boolean(trailing && trailing[0].length % 2 === 1);
}
/** @returns {string} */
function normalizeUnquotedHeredocLines(lines, stripTabs = false) {
const logical = lines
.map((line, index) => {
if (index === lines.length - 1) return line;
return hasLineContinuation(line) ? line.slice(0, -1) : `${line}\n`;
})
.join('');
return stripTabs ? logical.replace(/^\t+/, '') : logical;
}
/** @returns {{ text: string, nextIndex: number }} */
function readHeredocLine(lines, startIndex, quoted, stripTabs) {
if (quoted) {
const text = stripTabs ? lines[startIndex].replace(/^\t+/, '') : lines[startIndex];
return { text, nextIndex: startIndex + 1 };
}
let endIndex = startIndex;
while (endIndex < lines.length - 1 && hasLineContinuation(lines[endIndex])) endIndex += 1;
const text = normalizeUnquotedHeredocLines(lines.slice(startIndex, endIndex + 1), stripTabs);
return { text, nextIndex: endIndex + 1 };
}
/**
* Extract executable substitutions from an unquoted heredoc. Quote characters
* in its payload are literal and do not suppress expansion.
@@ -131,8 +160,8 @@ function findHeredocs(line) {
* @param {string[]} body
* @returns {string[]}
*/
function extractHeredocCommandSubstitutions(body) {
const text = body.join('\n');
function extractHeredocCommandSubstitutions(body, stripTabs) {
const text = normalizeUnquotedHeredocLines(body, stripTabs);
return [...new Set(extractCommandSubstitutions(text, { literalOuterQuotes: true }))];
}
@@ -145,14 +174,16 @@ function extractHeredocCommandSubstitutions(body) {
* @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;
let lineIndex = startIndex;
while (lineIndex < lines.length) {
const logical = readHeredocLine(lines, lineIndex, heredoc.quoted, heredoc.stripTabs);
if (logical.text !== heredoc.delimiter) {
lineIndex = logical.nextIndex;
continue;
}
const body = lines.slice(startIndex, lineIndex);
const substitutions = heredoc.quoted ? [] : extractHeredocCommandSubstitutions(body);
return { nextIndex: lineIndex + 1, substitutions };
const substitutions = heredoc.quoted ? [] : extractHeredocCommandSubstitutions(body, heredoc.stripTabs);
return { nextIndex: logical.nextIndex, substitutions };
}
return null;
}
+33
View File
@@ -1760,6 +1760,39 @@ function runTests() {
passed++;
else failed++;
if (
test('denies split command names after heredoc line continuation', () => {
expectDestructiveDeny(
['cat <<EOF', '$(r\\', 'm -rf /tmp/expanded-target', ')', 'EOF'].join('\n'),
'split command name in unquoted heredoc substitution'
);
})
)
passed++;
else failed++;
if (
test('denies split options after tab-stripped heredoc line continuation', () => {
expectDestructiveDeny(
['cat <<-EOF', '\t$(rm\\', '\t-rf /tmp/expanded-target)', 'EOF'].join('\n'),
'split option in tab-stripped unquoted heredoc substitution'
);
})
)
passed++;
else failed++;
if (
test('preserves internal tabs after tab-stripped heredoc continuations', () => {
expectAllow(
['cat <<-EOF', '\t$(r\\', '\tm -rf /tmp/expanded-target)', 'EOF'].join('\n'),
'internal tab after tab-stripped heredoc continuation'
);
})
)
passed++;
else failed++;
if (
test('fails closed on line-continued unquoted heredoc terminators', () => {
expectDestructiveDeny(