diff --git a/bin/gstack-launch-probe.sh b/bin/gstack-launch-probe.sh index 8fa4a35fe..135a7c0d6 100644 --- a/bin/gstack-launch-probe.sh +++ b/bin/gstack-launch-probe.sh @@ -31,9 +31,10 @@ gstack_launch_probe() { fi rc=$? # The first non-blank line, CRs dropped, at most 200 bytes: one subshell, not - # a five-process pipeline (each process start is ~20 ms on Windows). + # a five-process pipeline (each process start is ~20 ms on Windows). The + # pattern's leading ( keeps bash 3.2 (macOS /bin/bash) from ending $( at its ). _glp_detail=$(set -f; IFS=' -'; LC_ALL=C; for line in $out; do line=${line//$'\r'/}; case "$line" in *[![:space:]]*) printf '%s' "${line:0:200}"; break ;; esac; done) +'; LC_ALL=C; for line in $out; do line=${line//$'\r'/}; case "$line" in (*[![:space:]]*) printf '%s' "${line:0:200}"; break ;; esac; done) case "$out" in *[Aa]pplication\ [Cc]ontrol*|*"Smart App Control"*|*"blocked by group policy"*|*"has blocked this file"*) _glp_state=blocked; return 0 ;; diff --git a/test/bash32-case-in-substitution.test.ts b/test/bash32-case-in-substitution.test.ts new file mode 100644 index 000000000..daefa4f7e --- /dev/null +++ b/test/bash32-case-in-substitution.test.ts @@ -0,0 +1,51 @@ +/** + * #3601: macOS /bin/bash 3.2 cannot parse a case pattern without its leading + * ( inside $( ), and Linux CI's bash 5 can, so bin/gstack-launch-probe.sh + * shipped a parse error that made gstack-doctor call the probe "missing". + * The repo-wide case is the guard Linux CI can run; test/gstack-launch-probe + * .test.ts parses the probe with /bin/bash, which is bash 3.2 on macOS. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { scanBash32CaseInSubstitution } from './helpers/bash32-case-scan'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +function trackedShellScripts(): string[] { + const files = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.split('\0').filter(Boolean); + return files.filter(rel => { + if (/\.(md|ts|js|json|ya?ml|txt|tmpl)$/.test(rel)) return false; + const file = path.join(ROOT, rel); + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return false; + const first = fs.readFileSync(file, 'utf8').slice(0, 200).split('\n')[0]; + if (/^#!.*\b(zsh|python3?|node|bun|perl|ruby)\b/.test(first)) return false; + return rel.endsWith('.sh') || /^#!.*\b(bash|sh)\b/.test(first); + }); +} + +describe('bash 3.2: case patterns inside a substitution (#3601)', () => { + // Value: protects=the scanner flags exactly the shape bash 3.2 cannot parse; fails_when=it misses a bare pattern in $( ), one-line or multi-line, + // or flags a parenthesized pattern, a case outside a substitution, or quoted text; why_new=the scanner is new; seam=none + test('the scanner flags a bare pattern inside $( ) or <( ) and nothing else', () => { + const hits = (src: string) => scanBash32CaseInSubstitution(src).map(h => h.line); + expect(hits("x=$(case a in *[![:space:]]*) echo ok ;; esac)")).toEqual([1]); + expect(hits("x=$(\ncase a in\n (b) echo 1 ;;\n c) echo 2 ;;\nesac\n)")).toEqual([4]); + expect(hits("diff <(case a in b) echo ;; esac) /dev/null")).toEqual([1]); + expect(hits("x=$(case \"$v\" in (*[![:space:]]*) echo ok ;; (b|c) : ;; esac)")).toEqual([]); + expect(hits("case a in b) x=$(echo hi) ;; esac")).toEqual([]); + expect(hits("y=$(echo \"case a in b) no\")")).toEqual([]); + expect(hits("cat <<'EOF'\nx=$(case a in b) ;; esac)\nEOF\nz=$(echo ok)")).toEqual([]); + }); + + // Value: protects=every shell script gstack ships parses under macOS /bin/bash 3.2 (#3601); + // fails_when=a script puts a bare case pattern inside $( ); why_new=Linux CI runs bash 5, which accepts it; seam=none + test('no tracked shell script has a bare case pattern inside a substitution', () => { + const scripts = trackedShellScripts(); + expect(scripts).toContain('bin/gstack-launch-probe.sh'); + const hits = scripts.flatMap(rel => scanBash32CaseInSubstitution(fs.readFileSync(path.join(ROOT, rel), 'utf8')).map(h => `${rel}:${h.line}: ${h.text}`)); + expect(hits).toEqual([]); + }); + +}); diff --git a/test/gstack-launch-probe.test.ts b/test/gstack-launch-probe.test.ts index 5acc2c580..a39c20f03 100644 --- a/test/gstack-launch-probe.test.ts +++ b/test/gstack-launch-probe.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -80,4 +81,12 @@ describe.skipIf(process.platform === 'win32')('gstack-launch-probe.sh classifica expect(r.stdout).toContain('missing=[bin/gstack-global-discover]'); expect(r.stdout).not.toContain('design/dist/design'); }); + + // Value: protects=the probe parses under macOS /bin/bash 3.2, so gstack-doctor can source it (#3601); + // fails_when=a case pattern inside $( ) loses its leading (; why_new=the cases above run PATH bash, 5.x on Linux CI; seam=none + test.skipIf(!fs.existsSync('/bin/bash'))('parses under /bin/bash (bash 3.2 on macOS)', () => { + const r = spawnSync('/bin/bash', ['-n', PROBE], { encoding: 'utf8', timeout: 30_000 }); + expect(r.stderr).toBe(''); + expect(r.status).toBe(0); + }); }); diff --git a/test/helpers/bash32-case-scan.ts b/test/helpers/bash32-case-scan.ts new file mode 100644 index 000000000..801ec92e2 --- /dev/null +++ b/test/helpers/bash32-case-scan.ts @@ -0,0 +1,149 @@ +/** + * 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; +}