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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
bc7d776569
commit
8802d6a1a2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -25,3 +25,4 @@
|
|||
pub mod daemon;
|
||||
pub mod iso;
|
||||
pub mod pty;
|
||||
pub mod sigint_conformance;
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
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<Case> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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<i32>, 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<u8>, &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() {
|
||||
|
|
|
|||
|
|
@ -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<u8>, 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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue