Skip to main content

engenho_diff/
butai.rs

1//! The OCI-runtime differential — `butai` (or any runtime) against a
2//! reference binary, over the same bundle.
3//!
4//! ── ★ WHY THIS LIVES IN `engenho-diff` AND IS NOT ITS OWN HARNESS ─────────
5//! The butai plan called for a fresh `butai/tests/differential.rs`. It should
6//! not be fresh: this crate already IS the differential harness, generalized —
7//! typed operations fired at two targets, each observation folded through a
8//! [`Normalizer`] into a recursive JSON differ that yields a typed
9//! [`Verdict`]. Writing a second one would give the fleet two differs free to
10//! disagree about what counts as a difference, which is the failure this crate
11//! exists to prevent.
12//!
13//! What genuinely does NOT fit is [`crate::target::DiffTarget`]: it is
14//! HTTP-shaped (`method` + `path` → `{status, content_type, json}`) and an OCI
15//! runtime is a CLI (`create` / `start` / `state` / `kill` / `delete` →
16//! exit code + stdout + stderr). Same GOAL, different SHAPE — so this module
17//! adds its own observation and reuses the differ, the normalizer, the
18//! [`Divergence`] vocabulary and the [`Verdict`], rather than forcing an exit
19//! code into a `u16` HTTP status where it would read as one.
20//!
21//! ── ★ THE NEGATIVE CONTROL IS THE GATE ────────────────────────────────────
22//! A differential that has never failed proves nothing. The M0 gate is
23//! deliberately two runs: the same binary against itself must be
24//! [`Verdict::Parity`], and a binary against `/bin/false` must be
25//! [`Verdict::Divergent`] NAMING THE FIRST DIFFERING FIELD. Only the second
26//! shows the harness can see anything at all.
27
28use std::collections::BTreeSet;
29
30use serde_json::Value;
31
32use crate::cotejo::diff_object;
33use crate::normalize::Normalizer;
34use crate::verdict::{Divergence, Severity, Side};
35
36/// One subcommand of the OCI runtime lifecycle.
37///
38/// The set the runtime spec defines and containerd's shim actually drives.
39/// `state` is the one that carries a comparable document; the others are
40/// compared on their exit code and output.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
42pub enum RuntimeOp {
43    /// Create the container from the bundle; blocks until `start`.
44    Create,
45    /// Release the create/start rendezvous.
46    Start,
47    /// Report the container's OCI state document.
48    State,
49    /// Signal it.
50    Kill,
51    /// Remove it.
52    Delete,
53}
54
55impl RuntimeOp {
56    /// The subcommand as the binary spells it.
57    #[must_use]
58    pub fn as_str(self) -> &'static str {
59        match self {
60            Self::Create => "create",
61            Self::Start => "start",
62            Self::State => "state",
63            Self::Kill => "kill",
64            Self::Delete => "delete",
65        }
66    }
67}
68
69/// What one runtime did for one subcommand.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct RuntimeObservation {
72    /// Which subcommand.
73    pub op: RuntimeOp,
74    /// Process exit code. `None` when the process was signalled (no code).
75    pub exit_code: Option<i32>,
76    /// Raw stdout bytes.
77    pub stdout: Vec<u8>,
78    /// Raw stderr bytes.
79    ///
80    /// ★ COMPARED ONLY FOR EMPTINESS, never byte-for-byte. Two conformant
81    /// runtimes legitimately word their diagnostics differently, and a
82    /// differential that fails on prose is a differential nobody runs.
83    pub stderr: Vec<u8>,
84}
85
86impl RuntimeObservation {
87    /// The `state` document, when this observation carries one.
88    ///
89    /// A non-JSON stdout is `None` rather than an error: only `state` is
90    /// expected to emit a document, and the differ compares presence too.
91    #[must_use]
92    pub fn state_json(&self) -> Option<Value> {
93        if self.op != RuntimeOp::State {
94            return None;
95        }
96        serde_json::from_slice(&self.stdout).ok()
97    }
98
99    /// Whether the subcommand succeeded.
100    #[must_use]
101    pub fn is_success(&self) -> bool {
102        self.exit_code == Some(0)
103    }
104}
105
106/// Keys of the OCI `state` document that are legitimately per-run or
107/// per-runtime and must be masked before comparison.
108///
109/// ── ★ AN EXPLICIT KEY LIST, NEVER A REGEX ────────────────────────────────
110/// `pid` differs every run; `bundle` is an absolute path the harness chose.
111/// The runtime-private keys are the interesting ones: crun emits several
112/// non-spec keys that runc does not, and runc emits `annotations` that crun
113/// omits. Masking those by PATTERN would quietly swallow a real divergence
114/// that happened to share a prefix; naming them means adding a runtime costs
115/// an explicit line, which is the point.
116#[must_use]
117pub fn state_normalizer() -> Normalizer {
118    use crate::normalize::Mask;
119    use crate::path::JsonPath;
120    let mut n = Normalizer::default();
121    for key in [
122        // Per-run, on every runtime.
123        "pid",
124        "bundle",
125        // crun-private, absent from runc.
126        "systemd-scope",
127        "owner",
128        "created",
129        // runc-private, absent from crun.
130        "annotations",
131    ] {
132        n = n.with(Mask::Drop(JsonPath::parse(key)));
133    }
134    n
135}
136
137/// Compare one subcommand across two runtimes.
138///
139/// `reference` is the known-good binary (crun today), `under_test` the one
140/// being judged. Returns every hard divergence, first-listed first.
141#[must_use]
142pub fn diff_runtime_op(
143    reference: &RuntimeObservation,
144    under_test: &RuntimeObservation,
145    norm: &Normalizer,
146) -> Vec<Divergence> {
147    use crate::path::JsonPath;
148
149    assert_eq!(reference.op, under_test.op, "compare like with like");
150    let op = reference.op;
151
152    // 1. Exit code. A mismatch short-circuits — comparing the output of a
153    //    subcommand that failed on one side is comparing an error message to
154    //    a result.
155    if reference.exit_code != under_test.exit_code {
156        return vec![Divergence::FieldDiff {
157            path: JsonPath::parse(&format!("{}.exitCode", op.as_str())),
158            engenho: serde_json::json!(under_test.exit_code),
159            k3s: serde_json::json!(reference.exit_code),
160            severity: Severity::Hard,
161        }];
162    }
163    // Both failed identically ⇒ they agree.
164    if !reference.is_success() {
165        return Vec::new();
166    }
167
168    // 2. `state` carries a document; compare it under the mask.
169    if op == RuntimeOp::State {
170        return match (reference.state_json(), under_test.state_json()) {
171            (Some(r), Some(u)) => diff_object(&norm.apply(&u), &norm.apply(&r), Severity::Hard),
172            (r, u) => vec![Divergence::FieldDiff {
173                path: JsonPath::parse("state.document"),
174                engenho: serde_json::json!(if u.is_some() { "json" } else { "not json" }),
175                k3s: serde_json::json!(if r.is_some() { "json" } else { "not json" }),
176                severity: Severity::Hard,
177            }],
178        };
179    }
180
181    // 3. Every other subcommand: stdout must match byte-for-byte (these
182    //    produce no document, so any output at all is a difference), and
183    //    stderr is compared only for emptiness.
184    let mut out = Vec::new();
185    if reference.stdout != under_test.stdout {
186        out.push(Divergence::FieldDiff {
187            path: JsonPath::parse(&format!("{}.stdout", op.as_str())),
188            engenho: serde_json::json!(String::from_utf8_lossy(&under_test.stdout)),
189            k3s: serde_json::json!(String::from_utf8_lossy(&reference.stdout)),
190            severity: Severity::Hard,
191        });
192    }
193    if reference.stderr.is_empty() != under_test.stderr.is_empty() {
194        out.push(Divergence::FieldDiff {
195            path: JsonPath::parse(&format!("{}.stderr", op.as_str())),
196            engenho: serde_json::json!(if under_test.stderr.is_empty() {
197                "empty"
198            } else {
199                "non-empty"
200            }),
201            k3s: serde_json::json!(if reference.stderr.is_empty() {
202                "empty"
203            } else {
204                "non-empty"
205            }),
206            severity: Severity::Hard,
207        });
208    }
209    out
210}
211
212/// Compare a whole lifecycle run.
213///
214/// Both sides must have observed the SAME set of subcommands; a set mismatch
215/// is itself a divergence rather than a silent short run.
216#[must_use]
217pub fn diff_runtime_run(
218    reference: &[RuntimeObservation],
219    under_test: &[RuntimeObservation],
220    norm: &Normalizer,
221) -> Vec<Divergence> {
222    use crate::path::JsonPath;
223
224    let r_ops: BTreeSet<RuntimeOp> = reference.iter().map(|o| o.op).collect();
225    let u_ops: BTreeSet<RuntimeOp> = under_test.iter().map(|o| o.op).collect();
226    if r_ops != u_ops {
227        return vec![Divergence::FieldDiff {
228            path: JsonPath::parse("lifecycle.subcommands"),
229            engenho: serde_json::json!(format!("{u_ops:?}")),
230            k3s: serde_json::json!(format!("{r_ops:?}")),
231            severity: Severity::Hard,
232        }];
233    }
234    // In lifecycle order, so the FIRST divergence reported is the earliest
235    // point the two runtimes parted company — which is the one worth reading.
236    let mut out = Vec::new();
237    for op in r_ops {
238        let r = reference.iter().find(|o| o.op == op).expect("present");
239        let u = under_test.iter().find(|o| o.op == op).expect("present");
240        out.extend(diff_runtime_op(r, u, norm));
241    }
242    out
243}
244
245/// Which side a runtime differential calls which.
246///
247/// Re-exported for callers that report a divergence; the reference binary is
248/// [`Side::K3s`] because that is the vocabulary [`Divergence`] already speaks.
249#[must_use]
250pub fn reference_side() -> Side {
251    Side::K3s
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn obs(op: RuntimeOp, code: i32, stdout: &str) -> RuntimeObservation {
259        RuntimeObservation {
260            op,
261            exit_code: Some(code),
262            stdout: stdout.as_bytes().to_vec(),
263            stderr: Vec::new(),
264        }
265    }
266
267    fn state(json: &str) -> RuntimeObservation {
268        obs(RuntimeOp::State, 0, json)
269    }
270
271    #[test]
272    fn the_same_run_against_itself_is_parity() {
273        // The green half of the gate.
274        let run = vec![
275            obs(RuntimeOp::Create, 0, ""),
276            obs(RuntimeOp::Start, 0, ""),
277            state(r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":1,"bundle":"/a"}"#),
278            obs(RuntimeOp::Delete, 0, ""),
279        ];
280        assert!(diff_runtime_run(&run, &run, &state_normalizer()).is_empty());
281    }
282
283    #[test]
284    fn a_runtime_that_does_nothing_diverges_naming_the_field() {
285        // The RED half, and it is the gate — a differential that has never
286        // failed proves nothing. `/bin/false` exits 1 and prints nothing.
287        let good = vec![obs(RuntimeOp::Create, 0, "")];
288        let dead = vec![obs(RuntimeOp::Create, 1, "")];
289        let d = diff_runtime_run(&good, &dead, &state_normalizer());
290        assert_eq!(d.len(), 1, "{d:?}");
291        assert!(
292            format!("{:?}", d[0]).contains("exitCode"),
293            "the divergence must NAME the field: {:?}",
294            d[0]
295        );
296    }
297
298    #[test]
299    fn per_run_and_runtime_private_state_keys_are_masked() {
300        // pid differs every run; crun emits systemd-scope/owner/created that
301        // runc does not; runc emits annotations that crun does not. None of
302        // those is a divergence.
303        let crun = state(
304            r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":11,
305                "bundle":"/x","systemd-scope":"s","owner":"root","created":"t1"}"#,
306        );
307        let runc = state(
308            r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":22,
309                "bundle":"/y","annotations":{"a":"b"}}"#,
310        );
311        assert!(
312            diff_runtime_run(&[crun], &[runc], &state_normalizer()).is_empty(),
313            "only the spec-defined fields may be compared"
314        );
315    }
316
317    #[test]
318    fn a_real_state_difference_is_still_caught_through_the_mask() {
319        // NEGATIVE CONTROL for the mask: masking must not be so broad that it
320        // swallows the fields the differential exists to compare.
321        let a = state(r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":1}"#);
322        let b = state(r#"{"ociVersion":"1.0.2","id":"c","status":"stopped","pid":2}"#);
323        let d = diff_runtime_run(&[a], &[b], &state_normalizer());
324        assert!(!d.is_empty(), "a status difference must survive the mask");
325        assert!(format!("{d:?}").contains("status"), "{d:?}");
326    }
327
328    #[test]
329    fn a_short_run_is_a_divergence_not_a_silent_pass() {
330        // A runtime that never got to `state` must not compare equal to one
331        // that did just because the ops they share happen to agree.
332        let full = vec![obs(RuntimeOp::Create, 0, ""), state(r#"{"id":"c"}"#)];
333        let short = vec![obs(RuntimeOp::Create, 0, "")];
334        let d = diff_runtime_run(&full, &short, &state_normalizer());
335        assert_eq!(d.len(), 1);
336        assert!(format!("{d:?}").contains("subcommands"), "{d:?}");
337    }
338
339    #[test]
340    fn stderr_is_compared_for_emptiness_not_for_prose() {
341        // Two conformant runtimes word diagnostics differently; a
342        // differential that fails on prose is one nobody runs.
343        let mut a = obs(RuntimeOp::Kill, 0, "");
344        let mut b = obs(RuntimeOp::Kill, 0, "");
345        a.stderr = b"container not running\n".to_vec();
346        b.stderr = b"the container is not running\n".to_vec();
347        assert!(diff_runtime_run(&[a.clone()], &[b], &state_normalizer()).is_empty());
348        // But present-vs-absent IS a difference.
349        let quiet = obs(RuntimeOp::Kill, 0, "");
350        assert!(!diff_runtime_run(&[a], &[quiet], &state_normalizer()).is_empty());
351    }
352}