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::{ENVELOPE_SCHEMA, 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    if text(&envelope, "schema") != Some(ENVELOPE_SCHEMA) {
80        return Err(AcceptanceDefect::Shape);
81    }
82    let payload = member(&envelope, "payload").ok_or(AcceptanceDefect::Shape)?;
83    if text(payload, "schema") != Some(PAYLOAD_SCHEMA) {
84        return Err(AcceptanceDefect::Shape);
85    }
86    let recorded = text(&envelope, "payload_digest").ok_or(AcceptanceDefect::Shape)?;
87    if hj(PAYLOAD_SCHEMA, payload).to_string() != recorded {
88        return Err(AcceptanceDefect::PayloadDigest);
89    }
90    let engine_row = member(payload, "engine").ok_or(AcceptanceDefect::Shape)?;
91    if text(engine_row, "engine_digest") != Some(expectations.engine_digest.as_str()) {
92        return Err(AcceptanceDefect::Engine);
93    }
94    let evaluation = member(payload, "evaluation").ok_or(AcceptanceDefect::Shape)?;
95    let resolved = text(evaluation, "status") != Some("unavailable");
96    if resolved {
97        let base = member(evaluation, "base").ok_or(AcceptanceDefect::Shape)?;
98        if text(base, "commit_oid") != Some(expectations.base_commit.as_str()) {
99            return Err(AcceptanceDefect::BaseIdentity);
100        }
101        let candidate = member(evaluation, "candidate").ok_or(AcceptanceDefect::Shape)?;
102        if let (Some(expected), Some("git-commit")) = (
103            expectations.candidate_commit.as_deref(),
104            text(candidate, "kind"),
105        ) && text(candidate, "commit_oid") != Some(expected)
106        {
107            return Err(AcceptanceDefect::CandidateIdentity);
108        }
109    }
110    let result = member(payload, "result").ok_or(AcceptanceDefect::Shape)?;
111    let exit_code = match member(result, "exit_code") {
112        Some(Value::Integer(code)) => *code,
113        _ => return Err(AcceptanceDefect::Shape),
114    };
115    let complete = member(result, "complete") == Some(&Value::Bool(true));
116    if complete != (exit_code == 0 || exit_code == 1) {
117        return Err(AcceptanceDefect::Completeness);
118    }
119    let count = match member(result, "finding_count") {
120        Some(Value::Integer(count)) => *count,
121        _ => return Err(AcceptanceDefect::Shape),
122    };
123    let findings = match member(payload, "findings") {
124        Some(Value::Array(rows)) => rows.len(),
125        _ => return Err(AcceptanceDefect::Shape),
126    };
127    if i64::try_from(findings).map_err(|_defect| AcceptanceDefect::Shape)? != count {
128        return Err(AcceptanceDefect::FindingCount);
129    }
130    Ok(exit_code)
131}
132
133/// The watchdog outcome for one spawned engine process.
134#[derive(Debug)]
135pub enum Supervised {
136    /// The engine exited on its own within the ceiling.
137    Completed(ExitStatus),
138    /// The ceiling passed; the engine was killed and reaped. A killed engine
139    /// yields no accepted envelope.
140    Killed,
141}
142
143/// The operational wall-time watchdog: polls the engine until it exits or the
144/// ceiling passes, then kills and reaps it. The kill can never produce a
145/// partial result whose presence depends on runner speed; the caller fails the
146/// run without an envelope.
147///
148/// # Errors
149///
150/// Only `try_wait` failures; kill and reap errors after a timeout are
151/// deliberately ignored because the outcome is already `Killed`.
152pub fn supervise(child: &mut Child, ceiling: Duration) -> std::io::Result<Supervised> {
153    let start = Instant::now();
154    loop {
155        if let Some(status) = child.try_wait()? {
156            return Ok(Supervised::Completed(status));
157        }
158        if start.elapsed() >= ceiling {
159            let _signalled = child.kill();
160            let _reaped = child.wait();
161            return Ok(Supervised::Killed);
162        }
163        std::thread::sleep(Duration::from_millis(25));
164    }
165}
166
167/// Why a run produced no accepted result. Every one of these is a failed
168/// required check, and none of them publishes an envelope: a report the
169/// wrapper cannot accept is not a report.
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum Defect {
172    /// The engine outlived the wall ceiling and was killed.
173    Killed,
174    /// The engine died on a signal and carries no exit code.
175    Signalled,
176    /// The engine wrote more than the wire ceiling admits.
177    Oversize,
178    /// The engine's own exit code disagrees with the exit class it reported.
179    ExitMismatch,
180    /// The envelope failed the acceptance law.
181    Acceptance(AcceptanceDefect),
182}
183
184/// The settlement law, over what the wrapper can observe of a finished engine:
185/// its exit code and its complete stdout. An accepted envelope returns the
186/// exit class the wrapper then exits with, and which the engine's own process
187/// exit code must already equal. Nothing else is publishable.
188///
189/// # Errors
190///
191/// The defect that refused the result.
192pub fn settle(
193    outcome: &Supervised,
194    stdout: &[u8],
195    expectations: &Expectations,
196) -> Result<i64, Defect> {
197    let status = match *outcome {
198        Supervised::Killed => return Err(Defect::Killed),
199        Supervised::Completed(status) => status,
200    };
201    if u64::try_from(stdout.len()).unwrap_or(u64::MAX) > amiss_wire::report::MACHINE_JSON_BYTES {
202        return Err(Defect::Oversize);
203    }
204    let code = status.code().ok_or(Defect::Signalled)?;
205    let class = accept(stdout, expectations).map_err(Defect::Acceptance)?;
206    if i64::from(code) != class {
207        return Err(Defect::ExitMismatch);
208    }
209    Ok(class)
210}