fix(hooks): the args-heredoc guard lets through a heredoc operator the shell reads as text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
12bca8b953
commit
9f7ba6503f
3 changed files with 417 additions and 15 deletions
|
|
@ -307,6 +307,23 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
|
|||
// so a typed argument such as fix "<<" handling passes. Every other later
|
||||
// opener is checked, because the accepted opener may sit inside a string and
|
||||
// must not hide a real opener after it.
|
||||
// Two kinds of << are text to the shell and are skipped (fix round 4.5b,
|
||||
// part 4):
|
||||
// - A cut word whose quote closes a string opened earlier on its line (an
|
||||
// odd count of that quote from the line start to the opener's first <),
|
||||
// as in grep -n '<<' or a commit message holding "<<". A cut word with
|
||||
// no string open before it stays unreadable and is blocked as unquoted.
|
||||
// The skip is safe: the quote has no partner, so the word's body is
|
||||
// never read as a heredoc body, and each opener is read on its own, so
|
||||
// skipping it hides no later opener.
|
||||
// - A < after an odd run of backslashes (counted on the raw text, a line
|
||||
// break ends the run) starts no opener, in the regex reading and in the
|
||||
// shell reading alike, but only when no ', " or backquote and no <<
|
||||
// stands before the run: each of those can make the shell read the
|
||||
// backslashes once more (inside a backquote, or in an unquoted heredoc
|
||||
// body fed to a shell, measured), so there the < is read as unescaped.
|
||||
// The < after the escaped one is read as usual, so a << right after it
|
||||
// is still checked.
|
||||
// The shell reading fails closed: an error in it blocks (exit 2), because an
|
||||
// exit 1 is a non-blocking error and the command would run.
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
|
|
@ -326,6 +343,34 @@ function blockArgs(unquoted, unreadable) {
|
|||
process.exit(2);
|
||||
}
|
||||
|
||||
// Where the backslash rule stops applying in text: the first ', " or
|
||||
// backquote, and the end of the first << (continuations between the two <).
|
||||
function argsEscapeLimit(text) {
|
||||
const h = /<(?:\\\n)*</.exec(text);
|
||||
return { quoteAt: text.search(/['"`]/), heredocEnd: h ? h.index + h[0].length : -1 };
|
||||
}
|
||||
|
||||
// A < after an odd run of backslashes in the raw text (a line break ends the
|
||||
// run) is a literal < to the shell, when no ', " or backquote and no <<
|
||||
// stands before the run: inside quotes, backquotes or a heredoc body fed to
|
||||
// a shell, a backslash may be read once more.
|
||||
function argsEscapedLt(text, i, lim) {
|
||||
let b = 0;
|
||||
while (i - 1 - b >= 0 && text[i - 1 - b] === '\\') b += 1;
|
||||
const s = i - b;
|
||||
return b % 2 === 1 && (lim.quoteAt === -1 || lim.quoteAt >= s) &&
|
||||
(lim.heredocEnd === -1 || lim.heredocEnd > s);
|
||||
}
|
||||
|
||||
// A cut opener (its quote has no partner after it) whose quote closes a
|
||||
// string opened earlier on the raw line holding the opener's first <: an
|
||||
// odd count of that quote character from the line start to the <.
|
||||
function argsCutInString(text, o) {
|
||||
const q = text[o.end];
|
||||
const lineStart = text.lastIndexOf('\n', o.index) + 1;
|
||||
return text.slice(lineStart, o.index).split(q).length % 2 === 0;
|
||||
}
|
||||
|
||||
// Each heredoc operator in `text`, in text order, as the shell reads its word:
|
||||
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly }.
|
||||
function argsShellOpeners(text) {
|
||||
|
|
@ -336,8 +381,10 @@ function argsShellOpeners(text) {
|
|||
return k;
|
||||
};
|
||||
const openers = [];
|
||||
const lim = argsEscapeLimit(text);
|
||||
let i = text.indexOf('<');
|
||||
while (i !== -1) {
|
||||
if (argsEscapedLt(text, i, lim)) { i = text.indexOf('<', i + 1); continue; }
|
||||
let next = i + 1;
|
||||
let k = skip(i + 1);
|
||||
if (text[k] === '<') {
|
||||
|
|
@ -410,26 +457,29 @@ function argsShellOpeners(text) {
|
|||
}
|
||||
|
||||
if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
|
||||
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
|
||||
const argsLines = command.split('\n');
|
||||
const argsClosing = argsLines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
|
||||
const closesOnceAtEnd = argsClosing.length === 1 &&
|
||||
argsLines.slice(argsClosing[0] + 1).every((line) => line.trim() === '');
|
||||
for (const argsOpener of argsOpeners) {
|
||||
// Some line after the opener's line is non-blank (\s is the set trim() strips).
|
||||
const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index));
|
||||
if (!hasBody) continue;
|
||||
const [, open, close] = argsOpener;
|
||||
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
|
||||
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
|
||||
}
|
||||
// The shell reading. The loop records the first failing verdict; an error
|
||||
// anywhere in it blocks.
|
||||
// The regex reading and the shell reading. The shell reading's loop records
|
||||
// the first failing verdict; an error anywhere in either reading blocks.
|
||||
let argsVerdict = null;
|
||||
try {
|
||||
const argsLim = argsEscapeLimit(command);
|
||||
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
|
||||
const argsLines = command.split('\n');
|
||||
const argsClosing = argsLines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
|
||||
const closesOnceAtEnd = argsClosing.length === 1 &&
|
||||
argsLines.slice(argsClosing[0] + 1).every((line) => line.trim() === '');
|
||||
for (const argsOpener of argsOpeners) {
|
||||
if (argsEscapedLt(command, argsOpener.index, argsLim)) continue;
|
||||
// Some line after the opener's line is non-blank (\s is the set trim() strips).
|
||||
const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index));
|
||||
if (!hasBody) continue;
|
||||
const [, open, close] = argsOpener;
|
||||
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
|
||||
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
|
||||
}
|
||||
let bodyFrom = Infinity;
|
||||
for (const o of argsShellOpeners(command)) {
|
||||
if (o.index >= bodyFrom && o.cutOnly) continue;
|
||||
if (o.cutOnly && argsCutInString(command, o)) continue;
|
||||
// The word is compared trimmed, as the body lines are: a word of D plus
|
||||
// characters trim() strips (a CR from a CRLF line, a VT, an FF, a
|
||||
// no-break space) is closed by a line equal to it, which trims to D.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue