Skip to main content

amiss_bootstrap/
supervise.rs

1use std::process::{Child, ExitStatus};
2use std::time::{Duration, Instant};
3
4use amiss_wire::digest::hj;
5use amiss_wire::json::{Value, canonical, parse};
6use amiss_wire::report::PAYLOAD_SCHEMA;
7
8/// The exact acceptance defect, most specific first in evaluation order. The
9/// trusted wrapper publishes success only when acceptance returns no defect.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum AcceptanceDefect {
12    /// The bytes are not one parsable envelope with the expected members.
13    Shape,
14    /// The bytes are not exactly `JCS(envelope) || LF`.
15    Noncanonical,
16    /// The payload-only digest does not recompute.
17    PayloadDigest,
18    /// The engine digest differs from the binary the wrapper validated.
19    Engine,
20    /// The evaluated base identity differs from the one requested.
21    BaseIdentity,
22    /// The evaluated candidate identity differs from the one requested.
23    CandidateIdentity,
24    /// The completeness flag disagrees with the exit class.
25    Completeness,
26    /// The finding count differs from the findings array length.
27    FindingCount,
28}
29
30/// What the wrapper expects the accepted envelope to carry: the digest of the
31/// binary it validated and launched, and the identities it asked that binary
32/// to evaluate. A wrapper can only hold an engine to what it knows it
33/// requested.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct Expectations {
36    pub engine_digest: String,
37    pub base_commit: String,
38    pub candidate_commit: Option<String>,
39}
40
41fn member<'value>(value: &'value Value, key: &str) -> Option<&'value Value> {
42    match value {
43        Value::Object(members) => members
44            .iter()
45            .find(|(name, _)| name == key)
46            .map(|(_, member)| member),
47        Value::Null | Value::Bool(_) | Value::Integer(_) | Value::String(_) | Value::Array(_) => {
48            None
49        }
50    }
51}
52
53fn text<'value>(value: &'value Value, key: &str) -> Option<&'value str> {
54    match member(value, key) {
55        Some(Value::String(text)) => Some(text),
56        _ => None,
57    }
58}
59
60/// The acceptance law: the wire is exactly `JCS(envelope) || LF`, the
61/// payload-only digest recomputes, the engine digest equals the validated
62/// binary's, the evaluated identities equal the ones requested, the
63/// completeness flag agrees with the exit class, and the finding count equals
64/// the findings array length. Text printed before a crash is never
65/// interpreted as a result. Success returns the envelope's exit class, so the
66/// wrapper can hold the engine process to it.
67///
68/// # Errors
69///
70/// The first applicable defect in the order above.
71pub fn accept(wire: &[u8], expectations: &Expectations) -> Result<i64, AcceptanceDefect> {
72    let trimmed = wire
73        .strip_suffix(b"\n")
74        .ok_or(AcceptanceDefect::Noncanonical)?;
75    let envelope = parse(trimmed).map_err(|_defect| AcceptanceDefect::Shape)?;
76    if canonical(&envelope) != trimmed {
77        return Err(AcceptanceDefect::Noncanonical);
78    }
79    let payload = member(&envelope, "payload").ok_or(AcceptanceDefect::Shape)?;
80    let recorded = text(&envelope, "payload_digest").ok_or(AcceptanceDefect::Shape)?;
81    if hj(PAYLOAD_SCHEMA, payload).to_string() != recorded {
82        return Err(AcceptanceDefect::PayloadDigest);
83    }
84    let engine_row = member(payload, "engine").ok_or(AcceptanceDefect::Shape)?;
85    if text(engine_row, "engine_digest") != Some(expectations.engine_digest.as_str()) {
86        return Err(AcceptanceDefect::Engine);
87    }
88    let evaluation = member(payload, "evaluation").ok_or(AcceptanceDefect::Shape)?;
89    let resolved = text(evaluation, "status") != Some("unavailable");
90    if resolved {
91        let base = member(evaluation, "base").ok_or(AcceptanceDefect::Shape)?;
92        if text(base, "commit_oid") != Some(expectations.base_commit.as_str()) {
93            return Err(AcceptanceDefect::BaseIdentity);
94        }
95        let candidate = member(evaluation, "candidate").ok_or(AcceptanceDefect::Shape)?;
96        if let (Some(expected), Some("git-commit")) = (
97            expectations.candidate_commit.as_deref(),
98            text(candidate, "kind"),
99        ) && text(candidate, "commit_oid") != Some(expected)
100        {
101            return Err(AcceptanceDefect::CandidateIdentity);
102        }
103    }
104    let result = member(payload, "result").ok_or(AcceptanceDefect::Shape)?;
105    let exit_code = match member(result, "exit_code") {
106        Some(Value::Integer(code)) => *code,
107        _ => return Err(AcceptanceDefect::Shape),
108    };
109    let complete = member(result, "complete") == Some(&Value::Bool(true));
110    if complete != (exit_code == 0 || exit_code == 1) {
111        return Err(AcceptanceDefect::Completeness);
112    }
113    let count = match member(result, "finding_count") {
114        Some(Value::Integer(count)) => *count,
115        _ => return Err(AcceptanceDefect::Shape),
116    };
117    let findings = match member(payload, "findings") {
118        Some(Value::Array(rows)) => rows.len(),
119        _ => return Err(AcceptanceDefect::Shape),
120    };
121    if i64::try_from(findings).map_err(|_defect| AcceptanceDefect::Shape)? != count {
122        return Err(AcceptanceDefect::FindingCount);
123    }
124    Ok(exit_code)
125}
126
127/// The watchdog outcome for one spawned engine process.
128#[derive(Debug)]
129pub enum Supervised {
130    /// The engine exited on its own within the ceiling.
131    Completed(ExitStatus),
132    /// The ceiling passed; the engine was killed and reaped. A killed engine
133    /// yields no accepted envelope.
134    Killed,
135}
136
137/// The operational wall-time watchdog: polls the engine until it exits or the
138/// ceiling passes, then kills and reaps it. The kill can never produce a
139/// partial result whose presence depends on runner speed; the caller fails the
140/// run without an envelope.
141///
142/// # Errors
143///
144/// Only `try_wait` failures; kill and reap errors after a timeout are
145/// deliberately ignored because the outcome is already `Killed`.
146pub fn supervise(child: &mut Child, ceiling: Duration) -> std::io::Result<Supervised> {
147    let start = Instant::now();
148    loop {
149        if let Some(status) = child.try_wait()? {
150            return Ok(Supervised::Completed(status));
151        }
152        if start.elapsed() >= ceiling {
153            let _signalled = child.kill();
154            let _reaped = child.wait();
155            return Ok(Supervised::Killed);
156        }
157        std::thread::sleep(Duration::from_millis(25));
158    }
159}
160
161/// Why a run produced no accepted result. Every one of these is a failed
162/// required check, and none of them publishes an envelope: a report the
163/// wrapper cannot accept is not a report.
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub enum Defect {
166    /// The engine outlived the wall ceiling and was killed.
167    Killed,
168    /// The engine died on a signal and carries no exit code.
169    Signalled,
170    /// The engine wrote more than the wire ceiling admits.
171    Oversize,
172    /// The engine's own exit code disagrees with the exit class it reported.
173    ExitMismatch,
174    /// The envelope failed the acceptance law.
175    Acceptance(AcceptanceDefect),
176}
177
178/// The settlement law, over what the wrapper can observe of a finished engine:
179/// its exit code and its complete stdout. An accepted envelope returns the
180/// exit class the wrapper then exits with, and which the engine's own process
181/// exit code must already equal. Nothing else is publishable.
182///
183/// # Errors
184///
185/// The defect that refused the result.
186pub fn settle(
187    outcome: &Supervised,
188    stdout: &[u8],
189    expectations: &Expectations,
190) -> Result<i64, Defect> {
191    let status = match *outcome {
192        Supervised::Killed => return Err(Defect::Killed),
193        Supervised::Completed(status) => status,
194    };
195    if u64::try_from(stdout.len()).unwrap_or(u64::MAX) > amiss_wire::report::MACHINE_JSON_BYTES {
196        return Err(Defect::Oversize);
197    }
198    let code = status.code().ok_or(Defect::Signalled)?;
199    let class = accept(stdout, expectations).map_err(Defect::Acceptance)?;
200    if i64::from(code) != class {
201        return Err(Defect::ExitMismatch);
202    }
203    Ok(class)
204}