mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
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>
This commit is contained in:
1 parent
c830c9b8ba
commit
5b2b1478af
4 files changed
+212
-2
No files matched your search
@@ -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 ;;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user