From bc7d7765696ead254fec1a70dfae5c313384ffbc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 19 Aug 2026 21:14:30 +0200 Subject: [PATCH] feat(gate,test): implement revision 13 --- the validated (status, token) pair The helper now emits its verdict token on stdout with diagnostics on stderr, and both consumers validate the PAIR rather than the status alone. This closes the macOS defect CI found: a shell that cannot execute the helper exits 1, which the status-only ABI read as `ignored`, so a broken guard told the operator their environment ignores SIGINT. Gate (shell consumer): - guard-local capture directory, created before the gate's own temporary roots exist, with cleanup armed BEFORE the helper runs and disarmed on the safe path so the gate's later trap is undisturbed; - `|| sigint_status=$?` retained --- a bare invocation dies under `set -eu` before the status is read, which was the original bug; - `expected_token` selected by an explicit status case before any `set -u`-sensitive use, since an out-of-range status has none; - byte comparison via `cmp` against both permitted encodings, because a shell variable neither preserves NUL nor carries the child status; - the helper's stderr is surfaced ONLY for validated verdicts; a boundary failure prints the gate's own wording and withholds the untrusted child output; - every refusing branch prints status= and token=. R-d (Rust consumer) validates the same pair from Command::output() bytes. It needs no capture files, and its spawn-error path has no status at all --- the boundary the shell cannot represent. Conformance: 45 shared cases generated as a cross-product over token class, encoding and status, run by BOTH validators so they cannot diverge, plus Rust's X2 for 46 overall. 34 gate rows, 16 GPU rows, full gate green. Mutations, each biting its row: accepting any status 2 regardless of token; surfacing child stderr on a boundary failure; emitting the token to stderr. The first is caught by the dedicated error row rather than the conformance set --- most of the set's boundary cases have empty stderr, so they cannot tell which branch produced the exit 2 --- and that limitation is recorded rather than left implicit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 21 +++ scripts/check-sigint-deliverable | 28 +++- scripts/gate | 78 ++++++++-- tests/gate_script_acceptance.rs | 188 +++++++++++++++++++++- tests/gpu_invocation_acceptance.rs | 240 ++++++++++++++++++++++------- 5 files changed, 478 insertions(+), 77 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 6c6c097..98dc10f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -279,6 +279,27 @@ from #171 and #215. **`72da24a`**, worktree `/home/jeans/Repos/personal/pmacs-probe-sigint`. Recover with `git fetch githubsucks && git checkout gpu-probe-sigint-teardown`. +- **REVISION 13 IMPLEMENTED (this commit).** Helper emits the token on + stdout; both consumers validate the `(status, token)` pair. The gate + owns a guard-local capture dir, keeps `|| status=$?` under `set -eu`, + selects `expected_token` before any `set -u`-sensitive use, compares + bytes with `cmp` against both permitted encodings, surfaces the + helper's stderr **only** for validated verdicts, and prints + `status=`/`token=` on every refusing branch. R-d validates the same + pair from `Command::output()` bytes — no capture files, since only + the shell needs them. +- **Conformance: 45 shared cases, generated as a cross-product, run by + BOTH validators** (`gate_validates_the_whole_shared_conformance_set`, + `rd_precondition_validates_the_whole_conformance_set`), plus Rust's + X2 no-status spawn error = 46. **34 gate rows, 16 GPU rows, full gate + green.** +- **A4 mutations, each biting:** accepting any status 2 regardless of + token → `gate_refuses_on_helper_error_…`; surfacing child stderr on a + boundary failure → the conformance row **and** that row; token to + stderr → `sigint_helper_reports_safe_…`. Noted for the record: the + first mutation is caught by the dedicated row rather than by the + conformance set, because the set's boundary cases mostly have empty + stderr and so cannot see which branch produced the exit 2. - **CI ON `916007b`: 12 GREEN, 2 RED — both macOS `Test` jobs**, and it is the **pre-declared A7 portability finding**, not an environment excuse. Exactly one row: diff --git a/scripts/check-sigint-deliverable b/scripts/check-sigint-deliverable index fa4bf0c..1b7b8ce 100755 --- a/scripts/check-sigint-deliverable +++ b/scripts/check-sigint-deliverable @@ -10,12 +10,23 @@ # teardown defect. One lane spent nine framing revisions on exactly that # misreading; see docs/gpu-probe-sigint-framing.md §4c. # -# INTERFACE (docs/gpu-probe-sigint-framing.md §7c). Callers rely on -# these three statuses and MUST NOT re-derive the classification: +# INTERFACE (docs/gpu-probe-sigint-framing.md §7c) --- a validated +# (status, token) PAIR. Callers rely on both halves and MUST NOT +# re-derive the classification: # -# 0 safe SIGINT is deliverable. No diagnostic. -# 1 ignored SIGINT is inherited SIG_IGN. Canonical diagnostic. -# 2 error Undecidable. Distinct diagnostic. +# 0 safe stdout: pmacs-sigint-v1:safe no diagnostic +# 1 ignored stdout: pmacs-sigint-v1:ignored canonical diagnostic +# 2 error stdout: pmacs-sigint-v1:error distinct diagnostic +# +# The token is the ONLY thing on stdout; diagnostics go to stderr. Any +# other pair --- including a plausible status with no token --- is a +# BOUNDARY error for the caller, mapped to 2. +# +# WHY A TOKEN AND NOT A STATUS ALONE. CI proved a status-only ABI +# unsound: on macOS a shell that cannot execute this file exits 1, +# which the old ABI read as `ignored`, so a broken guard told the +# operator their environment ignores SIGINT. No exit status can prove +# this script ran; a token it must have printed can. # # `error` is never folded into `ignored`. "Your environment ignores # SIGINT" and "the guard could not run" are different problems, and @@ -35,12 +46,17 @@ sh -c 'trap "exit 23" 2 || exit 24; kill -INT "$$" || exit 24; exit 0' \ || probe_status=$? case "$probe_status" in - 23) exit 0 ;; + 23) + echo 'pmacs-sigint-v1:safe' + exit 0 + ;; 0) + echo 'pmacs-sigint-v1:ignored' echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2 exit 1 ;; *) + echo 'pmacs-sigint-v1:error' echo "pmacs: could not determine whether SIGINT is deliverable (probe status $probe_status)" >&2 exit 2 ;; diff --git a/scripts/gate b/scripts/gate index ec045ff..817bdd8 100755 --- a/scripts/gate +++ b/scripts/gate @@ -539,22 +539,78 @@ cd "$WT" # evidence, so a flag to proceed anyway would only manufacture red gates # that mean nothing. if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then + # The guard owns its capture directory: it runs BEFORE the log dir, + # ambient root and GATE_TMPDIR exist, and must still leave nothing + # behind (A8). + capture=$(mktemp -d "${TMPDIR:-/tmp}/pmacs-sigint.XXXXXX") || { + echo 'gate: could not create the SIGINT guard capture directory (status=unavailable token=missing)' >&2 + echo 'gate: REFUSING TO RUN --- no stage has run.' >&2 + exit 2 + } + cleanup_sigint_capture() { rm -rf "$capture"; } + trap cleanup_sigint_capture EXIT HUP INT TERM + + # `|| sigint_status=$?` IS LOAD-BEARING under `set -eu`: a bare + # invocation dies at the helper's non-zero exit and never reaches + # the assignment. That was the originally shipped bug. sigint_status=0 - "$WT/scripts/check-sigint-deliverable" || sigint_status=$? - # 1 and 2 are the helper's own verdicts and pass through unchanged. - # Anything else --- 126/127 for an unexecutable or missing helper, a - # signal death, any future status --- is an `error` AT THIS - # BOUNDARY, never evidence that SIGINT is ignored. + "$WT/scripts/check-sigint-deliverable" \ + >"$capture/out" 2>"$capture/err" || sigint_status=$? + + # Select an expected token only for public statuses. This MUST + # precede any use of expected_token: `set -u` is on, and an + # out-of-range status has no expected token. + expected_token= case "$sigint_status" in - 0) ;; - 1 | 2) - echo "gate: REFUSING TO RUN --- see the diagnosis above." >&2 - echo "gate: no stage has run; this is not a test failure." >&2 + 0) expected_token=pmacs-sigint-v1:safe ;; + 1) expected_token=pmacs-sigint-v1:ignored ;; + 2) expected_token=pmacs-sigint-v1:error ;; + esac + + # Byte comparison against both permitted encodings. Files preserve + # every byte including NUL; a shell variable would not, and command + # substitution's NUL handling differs between sh and zsh. + sigint_token_ok=0 + if [ -n "$expected_token" ]; then + printf '%s' "$expected_token" >"$capture/want" + printf '%s\n' "$expected_token" >"$capture/want_lf" + if cmp -s "$capture/out" "$capture/want" || + cmp -s "$capture/out" "$capture/want_lf"; then + sigint_token_ok=1 + fi + fi + + if [ ! -s "$capture/out" ]; then + sigint_token_state=missing + elif [ "$sigint_token_ok" -eq 1 ]; then + sigint_token_state=valid + else + sigint_token_state=unexpected + fi + + case "$sigint_status:$sigint_token_ok" in + 0:1) + # The sole continuing path. Tidy up and disarm before the + # gate installs its own, unrelated cleanup trap. + cleanup_sigint_capture + trap - EXIT HUP INT TERM + ;; + 1:1 | 2:1) + # A VALIDATED verdict: the helper's stderr IS the diagnosis + # and is surfaced unchanged. + cat "$capture/err" >&2 + printf 'gate: REFUSING TO RUN (status=%s token=%s) --- no stage has run.\n' \ + "$sigint_status" "$sigint_token_state" >&2 exit "$sigint_status" ;; *) - echo "gate: could not run the SIGINT guard (status $sigint_status)" >&2 - echo "gate: REFUSING TO RUN --- no stage has run." >&2 + # BOUNDARY error. The captured stderr is UNTRUSTED and is + # deliberately not surfaced: a helper exiting 1 with no + # token but the canonical ignored wording would otherwise + # tell the operator their environment ignores SIGINT (A6b). + printf 'gate: SIGINT guard boundary error (status=%s token=%s)\n' \ + "$sigint_status" "$sigint_token_state" >&2 + printf 'gate: REFUSING TO RUN --- no stage has run.\n' >&2 exit 2 ;; esac diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index b0685e7..bf022c9 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -184,7 +184,17 @@ fn gate_refuses_on_helper_error_without_claiming_sigint_is_ignored() { "an error verdict exits 2, not 1" ); let err = String::from_utf8_lossy(&out.stderr); - assert!(err.contains("could not determine"), "error wording: {err}"); + // This stub emits the error TEXT but no token, so under the pair + // ABI it is a BOUNDARY error --- and its stderr is untrusted, hence + // deliberately not surfaced. + assert!( + err.contains("SIGINT guard boundary error"), + "an unvalidated pair is a boundary error: {err}" + ); + assert!( + !err.contains("could not determine"), + "and the untrusted child stderr is NOT shown: {err}" + ); assert!( !err.contains("SIGINT is ignored"), "an undecidable probe must never be reported as ignored: {err}" @@ -220,15 +230,189 @@ fn gate_maps_an_unexecutable_helper_to_error_not_ignored() { "boundary failures map to 2; gate said:\n{err}" ); assert!( - err.contains("could not run the SIGINT guard"), + err.contains("SIGINT guard boundary error"), "the boundary has its own wording: {err}" ); + assert!( + err.contains("token=missing"), + "and names the token state, not just the status: {err}" + ); assert!( !err.contains("SIGINT is ignored"), "an unrunnable guard is not evidence about the signal: {err}" ); } +/// One stub-worktree gate run against a controlled `(status, stdout)` +/// pair, returning the gate's own exit code and stderr. +/// +/// The gate is the **shell consumer** of the pair ABI. Driving it +/// through a stub worktree exercises its real code path — including +/// the `set -eu` handling and the capture directory — against inputs +/// no real helper would produce. +fn gate_sees_pair(status: i32, stdout: &[u8], stderr_line: &str) -> (Option, String, PathBuf) { + let root = tempfile::tempdir().expect("tempdir"); + let octal = stdout.iter().fold(String::new(), |mut acc, b| { + use std::fmt::Write as _; + let _ = write!(acc, "\\{b:03o}"); + acc + }); + let repo = gate_with_stub_helper( + &format!("#!/bin/sh\nprintf '{octal}'\n{stderr_line}exit {status}\n"), + true, + ); + let out = Command::new(repo.path().join("scripts/gate")) + .arg("--self-test") + .current_dir(repo.path()) + .env("PMACS_GATE_TARGET_ROOT", root.path()) + .env("TMPDIR", root.path()) + .output() + .expect("run the stub-worktree gate"); + let code = out.status.code(); + let err = String::from_utf8_lossy(&out.stderr).into_owned(); + // Keep `root` alive for the residue check by returning its path + // after leaking the handle: the caller inspects it, then it is + // dropped with the TempDir at end of test. + let path = root.keep(); + (code, err, path) +} + +/// A6/A6b/A6c, shell consumer: the same 45 shared conformance cases the +/// Rust validator runs, so the two cannot diverge. +/// +/// X2 is absent by construction — a shell `exec` failure becomes a +/// shell status, so the shell boundary cannot present a status-less +/// spawn error. +#[test] +#[allow( + clippy::too_many_lines, + reason = "the bulk is the generated case list, which is data; splitting \ + it would put the cases and the expectations they encode in \ + different places" +)] +fn gate_validates_the_whole_shared_conformance_set() { + const SAFE: &[u8] = b"pmacs-sigint-v1:safe"; + const IGNORED: &[u8] = b"pmacs-sigint-v1:ignored"; + const ERROR: &[u8] = b"pmacs-sigint-v1:error"; + let toks: [(&str, &[u8]); 3] = [("safe", SAFE), ("ignored", IGNORED), ("error", ERROR)]; + + let mut cases: Vec<(String, i32, Vec, &str, bool)> = Vec::new(); + for (idx, (name, correct)) in toks.iter().enumerate() { + let status = i32::try_from(idx).expect("0..=2"); + let mut lf = correct.to_vec(); + lf.push(b'\n'); + // Only status 0's correct token is `safe`; the correct token at + // 1 and 2 is a VALIDATED verdict, which refuses with its own + // status rather than continuing. + let safe_here = status == 0; + cases.push(( + format!("{status}/V/{name}/bare"), + status, + correct.to_vec(), + "", + safe_here, + )); + cases.push((format!("{status}/V/{name}/lf"), status, lf, "", safe_here)); + for (other, bytes) in &toks { + if other == name { + continue; + } + let mut olf = bytes.to_vec(); + olf.push(b'\n'); + cases.push(( + format!("{status}/M/{other}/bare"), + status, + bytes.to_vec(), + "", + false, + )); + cases.push((format!("{status}/M/{other}/lf"), status, olf, "", false)); + } + let mut leading = vec![b'\n']; + leading.extend_from_slice(correct); + let mut extra = correct.to_vec(); + extra.extend_from_slice(b"\n\n"); + let mut spaces = b" ".to_vec(); + spaces.extend_from_slice(correct); + spaces.push(b' '); + let mut crlf = correct.to_vec(); + crlf.extend_from_slice(b"\r\n"); + let mut doubled = correct.to_vec(); + doubled.extend_from_slice(correct); + let mut nul = correct.to_vec(); + nul.push(0); + for (cls, bytes) in [ + ("E/empty", Vec::new()), + ("U/unknown", b"pmacs-sigint-v2:safe".to_vec()), + ("L/leading-lf", leading), + ("X/extra-lf", extra), + ("S/spaces", spaces), + ("C/crlf", crlf), + ("D/doubled", doubled), + ("N/nul", nul), + ] { + cases.push((format!("{status}/{cls}"), status, bytes, "", false)); + } + } + cases.push(("X1/status-126".to_owned(), 126, SAFE.to_vec(), "", false)); + cases.push(( + "X3/ignored-text-no-token".to_owned(), + 1, + Vec::new(), + "echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2\n", + false, + )); + cases.push(( + "X4/stderr-noise".to_owned(), + 0, + SAFE.to_vec(), + "echo 'chatter on stderr' >&2\n", + true, + )); + assert_eq!(cases.len(), 45, "the shared set is 45 cases"); + + for (name, status, stdout, stderr_line, expect_pass) in cases { + let (code, err, dir) = gate_sees_pair(status, &stdout, stderr_line); + if expect_pass { + // `safe` continues into the self-test plan, which exits + // non-zero ON PURPOSE — what matters is that the guard did + // not refuse. + assert!( + !err.contains("REFUSING TO RUN"), + "case {name}: the guard must not refuse a validated safe pair: {err}" + ); + } else { + assert!( + err.contains("REFUSING TO RUN"), + "case {name}: the guard must refuse: {err}" + ); + let validated = name.contains("/V/") && (status == 1 || status == 2); + if validated { + assert_eq!( + code, + Some(status), + "case {name}: validated verdicts pass their status through" + ); + } else { + assert_eq!(code, Some(2), "case {name}: boundary errors map to 2"); + assert!( + !err.contains("SIGINT is ignored"), + "case {name}: a boundary failure must not speak with the \ + helper's voice: {err}" + ); + } + } + // A8: no capture directory survives, on any path. + let residue: Vec<_> = std::fs::read_dir(&dir) + .expect("read tmpdir") + .filter_map(Result::ok) + .filter(|e| e.file_name().to_string_lossy().starts_with("pmacs-sigint.")) + .collect(); + assert!(residue.is_empty(), "case {name}: capture residue survived"); + std::fs::remove_dir_all(&dir).ok(); + } +} + /// §7c: the helper answers `safe` when `SIGINT` is deliverable. #[test] fn sigint_helper_reports_safe_when_the_signal_is_deliverable() { diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 5fb159d..4105adf 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -190,32 +190,78 @@ mod crdt { /// helper's own stderr. A helper that cannot be executed is an /// `error` at this boundary, never evidence that `SIGINT` is /// ignored. - /// The diagnosis for one helper invocation: `Ok` to proceed, `Err` - /// with the message a caller should fail on. + const SIGINT_TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe"; + const SIGINT_TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored"; + const SIGINT_TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error"; + + /// Does `stdout` carry exactly `token`, in one of the two permitted + /// encodings? Grammar is `TOKEN | TOKEN LF` **as bytes** — no + /// trimming, so a leading newline, a second newline, surrounding + /// spaces, CRLF, a doubled token or a trailing NUL all fail. + fn sigint_token_matches(stdout: &[u8], token: &[u8]) -> bool { + stdout == token + || (stdout.len() == token.len() + 1 + && stdout.starts_with(token) + && stdout[token.len()] == b'\n') + } + + /// The diagnosis for one helper invocation, validating the + /// `(status, token)` pair rather than the status alone. /// - /// Split from the assertion so the message itself is testable. - /// A6 requires this consumer to distinguish `error` from `ignored`, - /// and a diagnosis reachable only through a panic in a test that - /// cannot run under the condition it describes is not a witness. + /// A status arriving without its token did not come from this + /// helper — not hypothetical: on macOS a shell that cannot execute + /// the helper exits 1, which a status-only ABI read as `ignored` + /// (§4d). Rust compares `Command::output()` bytes directly; only + /// the shell consumer needs capture files. fn sigint_diagnosis(helper: &Path) -> Result<(), String> { let out = match Command::new(helper).output() { - Ok(out) => out, - // Failure to execute the helper is an `error` AT THIS - // BOUNDARY, never evidence that SIGINT is ignored. + // Rust's boundary differs from the shell's: a spawn error + // has NO status, where a shell turns the same failure into + // one. Conformance X2, Rust-only. Err(error) => { return Err(format!( - "precondition undecidable --- could not execute {}: {error}", + "precondition undecidable --- SIGINT guard boundary error \ + (status=unavailable token=missing): could not execute {}: {error}", helper.display() )); } + Ok(out) => out, }; - if out.status.success() { - return Ok(()); + let expected: &[u8] = match out.status.code() { + Some(0) => SIGINT_TOKEN_SAFE, + Some(1) => SIGINT_TOKEN_IGNORED, + Some(2) => SIGINT_TOKEN_ERROR, + _ => b"", + }; + let token_ok = !expected.is_empty() && sigint_token_matches(&out.stdout, expected); + let token_state = if out.stdout.is_empty() { + "missing" + } else if token_ok { + "valid" + } else { + "unexpected" + }; + let status = out + .status + .code() + .map_or_else(|| "signal".to_owned(), |c| c.to_string()); + match (out.status.code(), token_ok) { + (Some(0), true) => Ok(()), + // A VALIDATED verdict: the helper's stderr is the diagnosis. + (Some(1) | Some(2), true) => Err(format!( + "precondition failed --- this is NOT a teardown defect. \ + (status={status} token={token_state})\n{}", + String::from_utf8_lossy(&out.stderr).trim_end() + )), + // BOUNDARY: the child's stderr is UNTRUSTED and is not shown, + // or a helper exiting 1 with no token but the canonical + // ignored wording would still mislead the reader (A6b). + _ => Err(format!( + "precondition undecidable --- SIGINT guard boundary error \ + (status={status} token={token_state}). The helper's own \ + output is not trusted here and is not shown." + )), } - Err(format!( - "precondition failed --- this is NOT a teardown defect.\n{}", - String::from_utf8_lossy(&out.stderr).trim_end() - )) } fn sigint_helper_path() -> PathBuf { @@ -228,54 +274,132 @@ mod crdt { } } - /// A6, R-d consumer: the direct test distinguishes `error` from - /// `ignored`, and neither message claims a teardown defect. + /// The shared conformance set, generated rather than listed + /// (§7c): ten classes, two encodings where applicable, three + /// statuses = 42, plus X1/X3/X4. Only the diagonal validates. + fn sigint_conformance_cases() -> Vec<(String, i32, Vec, bool)> { + let toks: [(&str, &[u8]); 3] = [ + ("safe", SIGINT_TOKEN_SAFE), + ("ignored", SIGINT_TOKEN_IGNORED), + ("error", SIGINT_TOKEN_ERROR), + ]; + let mut out = Vec::new(); + for (idx, (name, correct)) in toks.iter().enumerate() { + let status = i32::try_from(idx).expect("0..=2"); + let ok = status == 0; + let mut lf = correct.to_vec(); + lf.push(b'\n'); + out.push(( + format!("{status}/V/{name}/bare"), + status, + correct.to_vec(), + ok, + )); + out.push((format!("{status}/V/{name}/lf"), status, lf, ok)); + // Every OTHER valid token, both encodings: enumerated, not + // sampled, since sampling one leaves half untested. + for (other, bytes) in &toks { + if other == name { + continue; + } + let mut olf = bytes.to_vec(); + olf.push(b'\n'); + out.push(( + format!("{status}/M/{other}/bare"), + status, + bytes.to_vec(), + false, + )); + out.push((format!("{status}/M/{other}/lf"), status, olf, false)); + } + let mut leading = vec![b'\n']; + leading.extend_from_slice(correct); + let mut extra = correct.to_vec(); + extra.extend_from_slice(b"\n\n"); + let mut spaces = b" ".to_vec(); + spaces.extend_from_slice(correct); + spaces.push(b' '); + let mut crlf = correct.to_vec(); + crlf.extend_from_slice(b"\r\n"); + let mut doubled = correct.to_vec(); + doubled.extend_from_slice(correct); + let mut nul = correct.to_vec(); + nul.push(0); + for (cls, bytes) in [ + ("E/empty", Vec::new()), + ("U/unknown", b"pmacs-sigint-v2:safe".to_vec()), + ("L/leading-lf", leading), + ("X/extra-lf", extra), + ("S/spaces", spaces), + ("C/crlf", crlf), + ("D/doubled", doubled), + ("N/nul", nul), + ] { + out.push((format!("{status}/{cls}"), status, bytes, false)); + } + } + out.push(( + "X1/status-126".to_owned(), + 126, + SIGINT_TOKEN_SAFE.to_vec(), + false, + )); + out.push(("X3/ignored-text-no-token".to_owned(), 1, Vec::new(), false)); + out.push(( + "X4/stderr-noise".to_owned(), + 0, + SIGINT_TOKEN_SAFE.to_vec(), + true, + )); + out + } + + /// A6/A6b/A6c, R-d consumer: the whole shared set, plus Rust's X2. #[test] - fn rd_precondition_distinguishes_ignored_from_error() { - // `safe` proceeds silently. - assert!( - sigint_diagnosis(&sigint_helper_path()).is_ok(), - "the foreground case must proceed" - ); - + fn rd_precondition_validates_the_whole_conformance_set() { let dir = tempfile::tempdir().expect("tempdir"); - let stub = |name: &str, body: &str, mode: u32| { - let path = dir.path().join(name); - fs::write(&path, body).expect("write stub"); - fs::set_permissions(&path, fs::Permissions::from_mode(mode)).expect("chmod stub"); - path - }; + let cases = sigint_conformance_cases(); + assert_eq!(cases.len(), 45, "the shared set is 45 cases"); - let ignored = stub( - "ignored", - "#!/bin/sh\necho 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2\nexit 1\n", - 0o755, - ); - let message = sigint_diagnosis(&ignored).expect_err("exit 1 must be refused"); - assert!(message.contains("SIGINT is ignored"), "{message}"); - assert!( - message.contains("NOT a teardown defect"), - "the whole point is not to read as a teardown defect: {message}" - ); + for (name, status, stdout, expect_ok) in cases { + let path = dir.path().join(name.replace('/', "_")); + let extra = if name.starts_with("X3") { + "echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2\n" + } else if name.starts_with("X4") { + "echo 'chatter on stderr' >&2\n" + } else { + "" + }; + let octal = stdout.iter().fold(String::new(), |mut acc, b| { + use std::fmt::Write as _; + let _ = write!(acc, "\\{b:03o}"); + acc + }); + fs::write( + &path, + format!("#!/bin/sh\nprintf '{octal}'\n{extra}exit {status}\n"), + ) + .expect("write stub"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod"); - let erroring = stub( - "erroring", - "#!/bin/sh\necho 'pmacs: could not determine whether SIGINT is deliverable (probe status 42)' >&2\nexit 2\n", - 0o755, - ); - let message = sigint_diagnosis(&erroring).expect_err("exit 2 must be refused"); - assert!(message.contains("could not determine"), "{message}"); - assert!( - !message.contains("SIGINT is ignored"), - "an undecidable probe is not evidence that SIGINT is ignored: {message}" - ); + let got = sigint_diagnosis(&path); + assert_eq!(got.is_ok(), expect_ok, "case {name}: got {got:?}"); + if let Err(message) = got { + let validated = name.contains("/V/") && (status == 1 || status == 2); + assert!( + validated || !message.contains("SIGINT is ignored"), + "case {name}: a boundary failure must not speak with the \ + helper's voice: {message}" + ); + } + } - // Not executable at all: an `error` at the boundary. - let unrunnable = stub("unrunnable", "#!/bin/sh\nexit 0\n", 0o644); - let message = sigint_diagnosis(&unrunnable).expect_err("an unrunnable helper must refuse"); + // X2 — Rust only: a shell exec failure becomes a status, so the + // shell consumer cannot present this input at all. + let message = sigint_diagnosis(&dir.path().join("absent")).expect_err("must not validate"); assert!( - message.contains("undecidable") && !message.contains("SIGINT is ignored"), - "boundary failure is undecidable, never ignored: {message}" + message.contains("status=unavailable") && !message.contains("SIGINT is ignored"), + "X2: {message}" ); }