fix(hooks): close a quote that follows a backslash in shell-substitution

toggleQuote refused to toggle when the previous character was a backslash, but every caller already consumes a backslash and the character after it outside single quotes. A quote reached after a backslash is therefore the end of `'C:\'` or the end of `"C:\\"`, and both stayed open, so a `(...)` group or `{ ...; }` group after them was never extracted. `echo "C:\\" ; (npm run dev)` passed the dev-server block while `echo "C:" ; (npm run dev)` was blocked.
This commit is contained in:
leoca committed 2026-10-29 15:05:58 +00:00
1 parent 25278e7d7b
commit eec6623a61
2 files changed
+18 -8

No files matched your search

+9 -8
View File
@@ -107,14 +107,15 @@ function parenDelta(ch) {
/**
* Toggle quote state for a quote character at the current position.
* Returns true when `ch` opened or closed a quote.
* Returns true when `ch` opened or closed a quote. Callers consume escapes
* first, so a quote after a backslash here still closes (`'C:\'`, `"C:\\"`).
*/
function toggleQuote(ch, prev, quotes) {
if (ch === "'" && !quotes.inDouble && prev !== '\\') {
function toggleQuote(ch, quotes) {
if (ch === "'" && !quotes.inDouble) {
quotes.inSingle = !quotes.inSingle;
return true;
}
if (ch === '"' && !quotes.inSingle && prev !== '\\') {
if (ch === '"' && !quotes.inSingle) {
quotes.inDouble = !quotes.inDouble;
return true;
}
@@ -135,7 +136,7 @@ function findParenSpanEnd(source, start) {
i += 2;
continue;
}
if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) {
if (!toggleQuote(inner, quotes) && !quotes.inSingle && !quotes.inDouble) {
depth += parenDelta(inner);
}
i += 1;
@@ -167,7 +168,7 @@ function findBacktickEnd(source, start) {
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 (toggleQuote(ch, 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);
@@ -192,7 +193,7 @@ function readSubshellBody(source, start) {
i = Math.min(i + 2, source.length);
continue;
}
if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) {
if (!toggleQuote(inner, quotes) && !quotes.inSingle && !quotes.inDouble) {
depth += parenDelta(inner);
if (depth === 0) break;
}
@@ -279,7 +280,7 @@ function readBraceBody(source, start) {
i = Math.min(i + 2, source.length);
continue;
}
if (toggleQuote(inner, prev, quotes) || quotes.inSingle || quotes.inDouble) {
if (toggleQuote(inner, quotes) || quotes.inSingle || quotes.inDouble) {
body += inner;
i += 1;
continue;
+9
View File
@@ -154,6 +154,12 @@ test('double-quoted parens are literal (bash only honors $() there)', () => {
test('extracts a bare (...) group while skipping an adjacent $()', () => {
assert.deepStrictEqual(extractSubshellGroups('$(a) (b)'), ['b']);
});
test('a double-quoted string ending in an escaped backslash still closes', () => {
assert.deepStrictEqual(extractSubshellGroups('echo "C:\\\\"; (npm run dev)'), ['npm run dev']);
});
test('a single-quoted string ending in a backslash still closes', () => {
assert.deepStrictEqual(extractSubshellGroups("echo 'C:\\'; (npm run dev)"), ['npm run dev']);
});
console.log('\nextractSubshellGroups - security-relevant:');
test('surfaces a destructive command inside a subshell', () => {
@@ -173,6 +179,9 @@ console.log('\nextractBraceGroups - basics:');
test('extracts a { ...; } body', () => {
assert.deepStrictEqual(extractBraceGroups('{ npm run dev; }'), [' npm run dev; ']);
});
test('extracts a { ...; } body after a string ending in an escaped backslash', () => {
assert.deepStrictEqual(extractBraceGroups('echo "C:\\\\"; { npm run dev; }'), [' npm run dev; ']);
});
test('nested brace group returns outer body then inner body', () => {
assert.deepStrictEqual(extractBraceGroups('{ a; { b; }; }'), [' a; { b; }; ', ' b; ']);
});