From eec6623a61cb91b1f4da995473027ebd89fef0ea Mon Sep 17 00:00:00 2001 From: leoca Date: Thu, 29 Oct 2026 15:05:58 +0000 Subject: [PATCH] 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. --- scripts/lib/shell-substitution.js | 17 +++++++++-------- tests/lib/shell-substitution.test.js | 9 +++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/scripts/lib/shell-substitution.js b/scripts/lib/shell-substitution.js index a5dec6fbb..8c6833046 100644 --- a/scripts/lib/shell-substitution.js +++ b/scripts/lib/shell-substitution.js @@ -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; diff --git a/tests/lib/shell-substitution.test.js b/tests/lib/shell-substitution.test.js index f64c90419..432e7c434 100644 --- a/tests/lib/shell-substitution.test.js +++ b/tests/lib/shell-substitution.test.js @@ -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; ']); });