From 8802d6a1a20897da04fb38e94df237b30696b198 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 09:15:47 +0200 Subject: [PATCH] test(sigint): share the conformance vectors and assert the exact branch Four acceptance gaps, all upheld. 1. Neither suite distinguished a validated refusal from a boundary error. Both exit 2 (and both produce Err in Rust), so comparing exit codes or is_ok() let a validator that accepts EVERY status-2 pair pass the whole matrix --- the precise defect A6c exists to catch. Every stub now emits a sentinel on stderr, and an Outcome enum (Safe / ValidatedIgnored / ValidatedError / Boundary) is asserted branch-exact: a validated verdict must surface the sentinel, a boundary failure must withhold it. Verified: mutating the gate to accept any status 2 now fails the MATRIX, where before it only failed a dedicated row. Each helper arm's exact stdout token is asserted as well. 2. The 45-case set was duplicated in both suites and could drift while both still reported length 45. It now lives in tests/common/sigint_conformance.rs and both validators consume the same vectors. 3. A8 was incomplete --- nothing forced capture-directory creation to fail. A bounded row points TMPDIR at a missing directory so `mktemp -d` fails, asserting boundary error 2, no stage execution and no residue; mutating the failure branch to fall through makes it fail. Temporary directories are RAII throughout, replacing the keep()-plus-manual-cleanup shape. 4. The R-d comment still claimed a shared helper means the consumers "can never disagree" and described status-only behaviour. Both were withdrawn by revision 13; the comment now points at the shared matrix as what actually keeps them in step. 36 gate rows, 16 GPU rows, clippy clean, full gate green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 19 ++- tests/common/mod.rs | 1 + tests/common/sigint_conformance.rs | 161 ++++++++++++++++++ tests/gate_script_acceptance.rs | 258 ++++++++++++----------------- tests/gpu_invocation_acceptance.rs | 170 ++++++------------- 5 files changed, 334 insertions(+), 275 deletions(-) create mode 100644 tests/common/sigint_conformance.rs diff --git a/docs/active-work.md b/docs/active-work.md index 98dc10f..33733f7 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -288,11 +288,20 @@ from #171 and #215. `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.** +- **Conformance vectors live in `tests/common/sigint_conformance.rs`** + and are consumed by BOTH validators, so the two copies cannot drift + while each still reports "45 cases". Every stub emits a **sentinel on + stderr**, which is what separates `ValidatedError` from `Boundary` — + they share exit 2, so comparing codes alone let a validator that + accepted every status 2 pass the whole matrix. An `Outcome` enum + (Safe / ValidatedIgnored / ValidatedError / Boundary) is asserted + branch-exact in both suites, and each helper arm's exact stdout token + is asserted too. +- **A8 is complete**: a bounded row points `TMPDIR` at a missing + directory so `mktemp -d` fails, and asserts boundary error 2, no + stage, and no residue. Temporary directories are RAII throughout; + the `keep()`-plus-manual-cleanup shape is gone. +- **36 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 diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7d2e4ca..5ecc4bd 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -25,3 +25,4 @@ pub mod daemon; pub mod iso; pub mod pty; +pub mod sigint_conformance; diff --git a/tests/common/sigint_conformance.rs b/tests/common/sigint_conformance.rs new file mode 100644 index 0000000..59eaeb6 --- /dev/null +++ b/tests/common/sigint_conformance.rs @@ -0,0 +1,161 @@ +//! Shared SIGINT-guard conformance vectors +//! (`docs/gpu-probe-sigint-framing.md` §7c). +//! +//! # Why these live here rather than in each suite +//! +//! The contract is that the **shell** consumer (`scripts/gate`) and the +//! **Rust** consumer (R-d's `sigint_diagnosis`) agree on every case. +//! Two independently written copies of the list can drift while both +//! still report "45 cases" — the same-length-different-content +//! divergence this matrix exists to rule out. One generator, two +//! consumers. + +/// What a consumer must do with a given `(status, stdout)` pair. +/// +/// `ValidatedError` and `Boundary` **both exit 2**, so a test comparing +/// only exit codes cannot separate them — and a validator that accepted +/// *every* status 2 would pass. They are told apart by whether the +/// child's stderr is surfaced: a validated verdict speaks with the +/// helper's voice; a boundary failure must not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Safe, + ValidatedIgnored, + ValidatedError, + Boundary, +} + +pub const TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe"; +pub const TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored"; +pub const TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error"; + +/// Emitted on stderr by **every** stub, so a consumer's output can be +/// searched for it: present ⇒ the child's stderr was surfaced. +pub const SENTINEL: &str = "PMACS-CONFORMANCE-SENTINEL"; + +pub struct Case { + pub name: String, + pub status: i32, + pub stdout: Vec, + pub expect: Outcome, +} + +/// A `/bin/sh` stub reproducing one case, sentinel included. +#[must_use] +pub fn stub_script(case: &Case) -> String { + let octal = case.stdout.iter().fold(String::new(), |mut acc, b| { + use std::fmt::Write as _; + let _ = write!(acc, "\\{b:03o}"); + acc + }); + format!( + "#!/bin/sh\nprintf '{octal}'\necho '{SENTINEL}' >&2\nexit {}\n", + case.status + ) +} + +/// The shared set: ten classes × encodings × three statuses, plus +/// X1/X3/X4. Only the diagonal validates. +/// +/// X2 — a spawn error with no status — is deliberately absent: the +/// shell boundary cannot represent it, because an `exec` failure there +/// becomes a status. Rust exercises it separately. +#[must_use] +pub fn shared_cases() -> Vec { + let toks: [(&str, &[u8]); 3] = [ + ("safe", TOKEN_SAFE), + ("ignored", TOKEN_IGNORED), + ("error", 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 diagonal = match status { + 0 => Outcome::Safe, + 1 => Outcome::ValidatedIgnored, + _ => Outcome::ValidatedError, + }; + let mut lf = correct.to_vec(); + lf.push(b'\n'); + out.push(Case { + name: format!("{status}/V/{name}/bare"), + status, + stdout: correct.to_vec(), + expect: diagonal, + }); + out.push(Case { + name: format!("{status}/V/{name}/lf"), + status, + stdout: lf, + expect: diagonal, + }); + for (other, bytes) in &toks { + if other == name { + continue; + } + let mut olf = bytes.to_vec(); + olf.push(b'\n'); + out.push(Case { + name: format!("{status}/M/{other}/bare"), + status, + stdout: bytes.to_vec(), + expect: Outcome::Boundary, + }); + out.push(Case { + name: format!("{status}/M/{other}/lf"), + status, + stdout: olf, + expect: Outcome::Boundary, + }); + } + 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(Case { + name: format!("{status}/{cls}"), + status, + stdout: bytes, + expect: Outcome::Boundary, + }); + } + } + out.push(Case { + name: "X1/status-126".to_owned(), + status: 126, + stdout: TOKEN_SAFE.to_vec(), + expect: Outcome::Boundary, + }); + out.push(Case { + name: "X3/ignored-text-no-token".to_owned(), + status: 1, + stdout: Vec::new(), + expect: Outcome::Boundary, + }); + out.push(Case { + name: "X4/stderr-noise".to_owned(), + status: 0, + stdout: TOKEN_SAFE.to_vec(), + expect: Outcome::Safe, + }); + out +} diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index bf022c9..fb7772d 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -28,6 +28,8 @@ //! `~/build/pmacs-gate-targets`, which matters most for the prune //! tests — a prune bug is unrecoverable. +mod common; + use std::path::{Path, PathBuf}; use std::process::Command; @@ -243,176 +245,132 @@ fn gate_maps_an_unexecutable_helper_to_error_not_ignored() { ); } -/// One stub-worktree gate run against a controlled `(status, stdout)` -/// pair, returning the gate's own exit code and stderr. +/// A6/A6b/A6c, shell consumer: the shared vectors, asserting the exact +/// branch rather than only the exit code. /// -/// 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. +/// `ValidatedError` and `Boundary` both exit 2, so comparing codes +/// alone would let a validator that accepts every status 2 pass. The +/// stubs emit a sentinel on stderr; a validated verdict surfaces it, a +/// boundary failure must withhold it. #[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)]; + use common::sigint_conformance::{Outcome, SENTINEL, shared_cases, stub_script}; - 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, - )); + let cases = shared_cases(); 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!( + for case in cases { + let root = tempfile::tempdir().expect("tempdir"); + let repo = gate_with_stub_helper(&stub_script(&case), 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 err = String::from_utf8_lossy(&out.stderr); + let name = &case.name; + + match case.expect { + Outcome::Safe => 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"); + "case {name}: a validated safe pair must continue: {err}" + ), + Outcome::ValidatedIgnored => { + assert_eq!(out.status.code(), Some(1), "case {name}: {err}"); assert!( - !err.contains("SIGINT is ignored"), - "case {name}: a boundary failure must not speak with the \ + err.contains(SENTINEL), + "case {name}: a validated verdict surfaces the helper's \ + stderr: {err}" + ); + assert!(err.contains("token=valid"), "case {name}: {err}"); + } + Outcome::ValidatedError => { + assert_eq!(out.status.code(), Some(2), "case {name}: {err}"); + assert!( + err.contains(SENTINEL), + "case {name}: a validated error also speaks with the \ helper's voice: {err}" ); + assert!(err.contains("token=valid"), "case {name}: {err}"); + } + Outcome::Boundary => { + assert_eq!(out.status.code(), Some(2), "case {name}: {err}"); + assert!( + err.contains("SIGINT guard boundary error"), + "case {name}: {err}" + ); + assert!( + !err.contains(SENTINEL), + "case {name}: a boundary failure must NOT surface the \ + child's stderr --- this is what separates it from a \ + validated error, which shares its exit code: {err}" + ); } } - // A8: no capture directory survives, on any path. - let residue: Vec<_> = std::fs::read_dir(&dir) + + // A8: no capture directory survives, on any path. `root` is RAII + // --- it is dropped at the end of this iteration. + let residue: Vec<_> = std::fs::read_dir(root.path()) .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(); } } +/// A8: the guard cannot create its capture directory. +/// +/// Bounded --- it never reaches a stage. `TMPDIR` points at a path that +/// does not exist, so `mktemp -d` fails and the guard must refuse +/// before running the helper at all. +#[test] +fn gate_refuses_when_the_capture_directory_cannot_be_created() { + let root = tempfile::tempdir().expect("tempdir"); + let repo = gate_with_stub_helper("#!/bin/sh\nprintf 'pmacs-sigint-v1:safe'\nexit 0\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().join("absent-directory")) + .output() + .expect("run the stub-worktree gate"); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "boundary error: {err}"); + assert!( + err.contains("capture directory"), + "the failure names what could not be created: {err}" + ); + assert!(err.contains("no stage has run"), "and no stage ran: {err}"); + assert!( + !String::from_utf8_lossy(&out.stdout).contains("[01]"), + "no stage may run" + ); +} + +/// Each helper arm emits its exact token on stdout. +#[test] +fn sigint_helper_emits_the_exact_token_for_each_arm() { + use common::sigint_conformance::{TOKEN_ERROR, TOKEN_SAFE}; + + let safe = Command::new(sigint_helper()).output().expect("run helper"); + assert_eq!(safe.status.code(), Some(0)); + assert_eq!( + safe.stdout, + [TOKEN_SAFE, b"\n"].concat(), + "the safe arm emits exactly its token plus one LF" + ); + + let erroring = Command::new(sigint_helper()) + .env("PATH", "") + .output() + .expect("run helper"); + assert_eq!(erroring.status.code(), Some(2)); + assert_eq!(erroring.stdout, [TOKEN_ERROR, b"\n"].concat()); +} + /// §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 4105adf..461d3e3 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -2,6 +2,8 @@ #![cfg(unix)] +mod common; + use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; @@ -183,13 +185,17 @@ mod crdt { /// teardown defect that is not there; that misreading cost nine /// framing revisions (§4c). /// - /// The **same checked-in helper the gate uses** owns the - /// classification and the wording, so the two can never disagree - /// about what "ignored" means. This consumer does not re-derive - /// either: it proceeds only on exit 0 and otherwise panics with the - /// helper's own stderr. A helper that cannot be executed is an - /// `error` at this boundary, never evidence that `SIGINT` is - /// ignored. + /// Both consumers use the **same checked-in helper**, but that alone + /// no longer makes them agree: each validates the + /// `(status, token)` pair independently, in a different language. + /// Revision 12's "they can never disagree" is withdrawn, and the + /// shared matrix in `tests/common/sigint_conformance.rs` replaces + /// it — both validators run the same vectors. + /// + /// This consumer proceeds only on a validated `(0, safe)` pair. On a + /// **boundary** failure the helper's stderr is untrusted and is + /// withheld; only a validated verdict speaks with the helper's + /// voice. 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"; @@ -274,131 +280,55 @@ mod crdt { } } - /// 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. + /// A6/A6b/A6c, R-d consumer: the shared vectors, asserting the exact + /// branch. + /// + /// `ValidatedError` and `Boundary` both produce `Err`, so comparing + /// `is_ok()` alone would let a validator that accepts every status 2 + /// pass. The stubs emit a sentinel on stderr; a validated verdict + /// surfaces it, a boundary failure must not. #[test] fn rd_precondition_validates_the_whole_conformance_set() { + use crate::common::sigint_conformance::{Outcome, SENTINEL, shared_cases, stub_script}; + let dir = tempfile::tempdir().expect("tempdir"); - let cases = sigint_conformance_cases(); + let cases = shared_cases(); assert_eq!(cases.len(), 45, "the shared set is 45 cases"); - 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"); + for case in cases { + let path = dir.path().join(case.name.replace('/', "_")); + fs::write(&path, stub_script(&case)).expect("write stub"); fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod"); - 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}" - ); + let name = &case.name; + match case.expect { + Outcome::Safe => assert!(got.is_ok(), "case {name}: {got:?}"), + Outcome::ValidatedIgnored | Outcome::ValidatedError => { + let message = got.expect_err("a validated refusal"); + assert!( + message.contains(SENTINEL), + "case {name}: a validated verdict surfaces the helper's stderr: {message}" + ); + assert!(message.contains("token=valid"), "case {name}: {message}"); + } + Outcome::Boundary => { + let message = got.expect_err("a boundary refusal"); + assert!(message.contains("boundary error"), "case {name}: {message}"); + assert!( + !message.contains(SENTINEL), + "case {name}: a boundary failure must NOT surface the child's \ + stderr --- that is what separates it from a validated error, \ + which shares its outcome type: {message}" + ); + } } } - // X2 — Rust only: a shell exec failure becomes a status, so the - // shell consumer cannot present this input at all. + // X2 --- Rust only: a shell exec failure becomes a status, so the + // shell consumer cannot present a status-less spawn error. let message = sigint_diagnosis(&dir.path().join("absent")).expect_err("must not validate"); assert!( - message.contains("status=unavailable") && !message.contains("SIGINT is ignored"), + message.contains("status=unavailable") && !message.contains(SENTINEL), "X2: {message}" ); }