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}" ); }