Files
gstack/test/helpers/bash32-case-scan.ts
T
e5107cc7e3 fix(doctor): parse the launch probe under macOS /bin/bash 3.2 (#3601) (#3753)
* fix(doctor): parse the launch probe under macOS /bin/bash 3.2 (#3601)

bash 3.2 ends a $( ) at the ) of a case pattern that has no leading (, so
macOS /bin/bash could not parse bin/gstack-launch-probe.sh and gstack-doctor
reported the probe missing. The pattern takes its POSIX leading (. A static
scanner, which Linux CI runs, fails any tracked shell script with a bare case
pattern inside a substitution, and the probe test parses the file with
/bin/bash.

Co-authored-by: 김보경 <rlaqhrud6263@naver.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: fragment for the bash 3.2 launch probe fix (#3601)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(launch-probe): say the /bin/bash parse case discriminates only on macOS

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: the probe parse test catches #3601 only on a Mac

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: move the /bin/bash parse case out of the windows-smoke probe test (#3601)

Main's windows-smoke runs test/gstack-launch-probe.test.ts, and a hardcoded
/bin/bash path takes a file out of the Windows-safe set, so the merged tree
failed windows-native-workflows. The case now lives in
test/bash32-case-in-substitution.test.ts with the Linux scan.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: 김보경 <rlaqhrud6263@naver.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-05 00:50:43 +00:00

150 lines
6.8 KiB
TypeScript

/**
* bash 3.2 (macOS /bin/bash) ends a `$( ... )` at the `)` of a case pattern
* that has no leading `(`, so a script bash 5 parses fails to parse there
* (#3601: gstack-doctor reported the launch probe "missing"). Linux CI runs
* bash 5, so this finds the shape statically: a case pattern without its
* optional leading `(` while a command or process substitution is open. It is
* a small tokenizer, not a shell parser: quotes, `$'..'`, escapes, comments,
* here-documents, `${..}`, `$((..))`, backquotes and nested case statements are
* tracked; everything else is ordinary text.
*/
export interface Bash32CaseHit { line: number; text: string }
type Frame = 'comsub' | 'paren' | 'arith' | 'brace' | 'dq' | 'bq';
interface CaseState { mode: 'subject' | 'in' | 'pattern' | 'body'; depth: number }
const WORD_CHAR = /[A-Za-z0-9_-]/;
export function scanBash32CaseInSubstitution(src: string): Bash32CaseHit[] {
const hits: Bash32CaseHit[] = [];
const lines = src.split('\n');
const frames: Frame[] = [];
const cases: CaseState[] = [];
const heredocs: { tag: string; strip: boolean }[] = [];
let line = 1;
let i = 0;
const top = () => frames[frames.length - 1];
const atWordStart = (j: number) => j === 0 || /[\s;&|()`]/.test(src[j - 1]);
const readWord = (j: number) => { let k = j; while (k < src.length && WORD_CHAR.test(src[k])) k++; return src.slice(j, k); };
const closeQuote = (j: number, q: string) => {
let k = j + 1;
while (k < src.length && src[k] !== q) { if (q === '"' && src[k] === '\\') k++; if (src[k] === '\n') line++; k++; }
return k + 1;
};
const newline = () => {
line++;
i++;
while (heredocs.length) {
const { tag, strip } = heredocs.shift()!;
while (i < src.length) {
const end = src.indexOf('\n', i);
const text = src.slice(i, end < 0 ? src.length : end);
i = end < 0 ? src.length : end + 1;
line++;
if ((strip ? text.replace(/^\t+/, '') : text) === tag) break;
}
}
};
const skipSubject = (j: number) => {
let k = j;
while (k < src.length && !/\s/.test(src[k])) {
if (src[k] === '"' || src[k] === "'") { k = closeQuote(k, src[k]); continue; }
if (src[k] === '$' && src[k + 1] === '{') { const e = src.indexOf('}', k); k = e < 0 ? src.length : e + 1; continue; }
if (src[k] === '\\') k++;
k++;
}
return k;
};
/** One pattern list up to its closing `)`; a bare one inside a substitution is a hit. */
const scanPattern = (j: number, bare: boolean) => {
let k = bare ? j : j + 1;
while (k < src.length) {
const ch = src[k];
if (ch === '\\') { k += 2; continue; }
if (ch === '"' || ch === "'") { k = closeQuote(k, ch); continue; }
if (ch === '[') { const e = src.indexOf(']', k + 2); if (e > 0 && !src.slice(k, e).includes('\n')) { k = e + 1; continue; } }
if (ch === '$' && src[k + 1] === '{') { const e = src.indexOf('}', k); k = e < 0 ? src.length : e + 1; continue; }
if (ch === ')') {
if (bare && frames.includes('comsub')) hits.push({ line, text: (lines[line - 1] ?? '').trim().slice(0, 160) });
cases[cases.length - 1].mode = 'body';
return k + 1;
}
if (ch === '\n') line++;
k++;
}
return k;
};
while (i < src.length) {
const c = src[i];
const cs = cases[cases.length - 1];
const caseHere = cs !== undefined && cs.depth === frames.length;
if (c === '\n' && top() !== 'dq' && top() !== 'bq') { newline(); continue; }
if (c === '\n') { line++; i++; continue; }
if (c === '\\') { if (src[i + 1] === '\n') line++; i += 2; continue; }
if (caseHere && cs.mode === 'subject') {
if (c === ' ' || c === '\t') { i++; continue; }
i = skipSubject(i); cs.mode = 'in'; continue;
}
if (top() === 'dq') {
if (c === '"') frames.pop();
else if (c === '$' && src[i + 1] === '(' && src[i + 2] === '(') { frames.push('arith'); i += 3; continue; }
else if (c === '$' && src[i + 1] === '(') { frames.push('comsub'); i += 2; continue; }
else if (c === '$' && src[i + 1] === '{') { frames.push('brace'); i += 2; continue; }
else if (c === '`') frames.push('bq');
i++; continue;
}
if (c === "'") { i = closeQuote(i, "'"); continue; }
if (c === '$' && src[i + 1] === "'") {
let k = i + 2;
while (k < src.length && src[k] !== "'") { if (src[k] === '\\') k++; if (src[k] === '\n') line++; k++; }
i = k + 1; continue;
}
if (c === '"') { frames.push('dq'); i++; continue; }
if (c === '`') { if (top() === 'bq') frames.pop(); else frames.push('bq'); i++; continue; }
if (top() === 'brace') {
if (c === '}') frames.pop();
else if (c === '$' && src[i + 1] === '{') { frames.push('brace'); i += 2; continue; }
i++; continue;
}
if (top() === 'arith') {
if (c === ')' && src[i + 1] === ')') { frames.pop(); i += 2; continue; }
if (c === '(') frames.push('paren');
i++; continue;
}
if (c === '#' && atWordStart(i)) { const e = src.indexOf('\n', i); i = e < 0 ? src.length : e; continue; }
if (c === '<' && src[i + 1] === '<' && src[i + 2] !== '<') {
let k = i + 2;
const strip = src[k] === '-';
if (strip) k++;
while (src[k] === ' ' || src[k] === '\t') k++;
const m = /^(['"]?)([A-Za-z0-9_.-]+)\1/.exec(src.slice(k, k + 80));
if (m) { heredocs.push({ tag: m[2], strip }); i = k + m[0].length; continue; }
i += 2; continue;
}
if (c === '$' && src[i + 1] === '(' && src[i + 2] === '(') { frames.push('arith'); i += 3; continue; }
if ((c === '$' || c === '<' || c === '>') && src[i + 1] === '(') { frames.push('comsub'); i += 2; continue; }
if (c === '$' && src[i + 1] === '{') { frames.push('brace'); i += 2; continue; }
if (caseHere && cs.mode === 'pattern') {
if (c === ' ' || c === '\t') { i++; continue; }
if (readWord(i) === 'esac') { cases.pop(); i += 4; continue; }
i = scanPattern(i, c !== '('); continue;
}
if (caseHere && cs.mode === 'body' && c === ';' && (src[i + 1] === ';' || src[i + 1] === '&')) {
cs.mode = 'pattern'; i += src[i + 1] === ';' && src[i + 2] === '&' ? 3 : 2; continue;
}
if (c === '(') { frames.push('paren'); i++; continue; }
if (c === ')') { if (top() === 'comsub' || top() === 'paren') frames.pop(); i++; continue; }
if (atWordStart(i) && WORD_CHAR.test(c)) {
const w = readWord(i);
const whole = i + w.length >= src.length || /[\s;&|()]/.test(src[i + w.length]);
if (whole && caseHere && cs.mode === 'in' && w === 'in') { cs.mode = 'pattern'; i += 2; continue; }
if (whole && w === 'case') { cases.push({ mode: 'subject', depth: frames.length }); i += 4; continue; }
if (whole && w === 'esac' && caseHere) { cases.pop(); i += 4; continue; }
i += w.length; continue;
}
i++;
}
return hits;
}