Files
gstack/test/helpers/bash-script.ts
T
Garry TanandClaude Fable 5.1 1204828699 test: run assembled setup harness scripts from a temp file, not bash -c argv (Windows MSYS2 8 KB truncation)
windows-free-tests (run 33907177851) failed in
test/setup-alias-name-uniqueness.test.ts with
  bash: -c: line 178: unexpected EOF while looking for matching `'
The harness slices functions out of `setup` and passed the joined script as
one `bash -c` argv element. The ownership gate grew that script from 6.7 KB
to 15.7 KB, and on Windows bash is an MSYS2 program: when its parent is a
non-MSYS process (bun), msys-2.0.dll's build_argv() runs any argument
containing `?*["'(){}` through globify()/glob(), which copies the pattern
into a fixed `Char patbuf[8192]` and silently stops after 8192 - MB_CUR_MAX
(8186 chars under C.UTF-8); GLOB_NOCHECK then returns the truncated text as
the argument. Character 8186 lands inside the single-quoted sed token on
line 178. Rebuilding the exact script with CI path shapes and cutting it at
8186-8190 characters reproduces the identical message locally; cmd.exe's
8191-UTF-16 cap and CreateProcess's 32767 do not fit the evidence.

Fix: test/helpers/bash-script.ts writes the script to a temp file and runs
`bash <path>` — a short glob-free argument that never enters globify. Every
setup harness that assembled a script for `bash -c` (11 files, 22 sites)
uses it; timeouts and env are preserved verbatim, spawn/timeout errors are
appended to stderr, temp cleanup is best-effort. `spawnSync('bash',
[<Windows absolute path>])` already passes on windows-latest in setup-help,
uninstall-windows-copies and the migration tests. The Windows-curated list
is byte-identical before and after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 23:05:59 +00:00

61 lines
2.6 KiB
TypeScript

/**
* Run an assembled bash script from a TEMP FILE — never via `bash -c <argv>`.
*
* Why: the setup harnesses slice functions out of `setup` and join them into
* one script. On Windows, bash is an MSYS2 program; when its parent is a
* non-MSYS process (bun), msys-2.0.dll's build_argv() runs every argument
* containing any of `?*["'(){}` through globify()/glob(). glob() copies the
* pattern into a fixed `Char patbuf[8192]` and silently stops after
* 8192 - MB_CUR_MAX (8186 characters under C.UTF-8); GLOB_NOCHECK then hands
* the truncated text to bash as the argument. Observed on windows-free-tests
* when the alias harness grew from 6.7 KB to 15.7 KB: the `-c` script was cut
* inside a single-quoted token on line 178 ("unexpected EOF while looking for
* matching `'"). A short, glob-character-free file path never enters
* globify, and bash reads the file's bytes directly, so quoting and encoding
* are never re-parsed by any argument layer. The same ~8186-char ceiling
* applies to any MSYS tool (sh, sed, awk, grep) spawned from bun on Windows
* with a long single argument.
*
* `timeout` is always set (test/spawnsync-timeout-tripwire.test.ts).
*/
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
export interface BashScriptResult {
status: number;
stdout: string;
stderr: string;
signal: NodeJS.Signals | null;
}
export interface BashScriptOptions {
timeout?: number;
env?: NodeJS.ProcessEnv;
cwd?: string;
}
export function runBashScript(script: string, opts: BashScriptOptions = {}): BashScriptResult {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bash-script-'));
const file = path.join(dir, 'script.sh');
// LF only: a stray CR would reach bash as part of a token.
fs.writeFileSync(file, script.replace(/\r\n/g, '\n'));
try {
const r = spawnSync('bash', [file], {
encoding: 'utf-8',
timeout: opts.timeout ?? 60_000,
...(opts.env ? { env: opts.env } : {}),
...(opts.cwd ? { cwd: opts.cwd } : {}),
});
// A spawn failure (bash missing) or a timeout kill has no bash stderr of
// its own; surface the cause instead of a bare status -1.
const stderr = (r.stderr ?? '') + (r.error ? `\n[spawn] ${r.error.message}` : '');
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr, signal: r.signal ?? null };
} finally {
// Best-effort: an AV scanner or indexer still holding script.sh on
// Windows must never turn a good result into an unlink error.
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort temp cleanup */ }
}
}