Skip to main content

amiss_bootstrap/
supervise.rs

1use std::process::{Child, ExitStatus};
2use std::time::{Duration, Instant};
3
4use amiss_wire::controls::{ExecutionConstraintDescriptor, TrustedTimeStatement};
5use amiss_wire::digest::hj;
6use amiss_wire::json::{Value, canonical, parse};
7use amiss_wire::model::RepositoryIdentity;
8use amiss_wire::report::{ENVELOPE_SCHEMA, PAYLOAD_SCHEMA};
9use amiss_wire::requests::CANDIDATE_IDENTITY_DOMAIN;
10
11/// The exact acceptance defect, most specific first in evaluation order. The
12/// trusted wrapper publishes success only when acceptance returns no defect.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum AcceptanceDefect {
15    /// The bytes are not one parsable envelope with the expected members.
16    Shape,
17    /// The bytes are not exactly `JCS(envelope) || LF`.
18    Noncanonical,
19    /// The payload-only digest does not recompute.
20    PayloadDigest,
21    /// The engine digest differs from the binary the wrapper validated.
22    Engine,
23    /// The evaluated base identity differs from the one requested.
24    BaseIdentity,
25    /// The evaluated candidate identity differs from the one requested.
26    CandidateIdentity,
27    /// The report does not bind the sealed refs and candidate identity.
28    SealedIdentity,
29    /// The report does not carry the exact sealed controls and provider run.
30    SealedControls,
31    /// The completeness flag disagrees with the exit class.
32    Completeness,
33    /// The finding count differs from the findings array length.
34    FindingCount,
35}
36
37/// What the wrapper expects the accepted envelope to carry: the digest of the
38/// binary it validated and launched, and the identities it asked that binary
39/// to evaluate. A wrapper can only hold an engine to what it knows it
40/// requested.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct Expectations {
43    pub engine_digest: String,
44    pub base_commit: String,
45    pub candidate_commit: Option<String>,
46    pub sealed: Option<SealedExpectations>,
47}
48
49/// The independently captured provider and control context a sealed report
50/// must reproduce before the bootstrap will publish it.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct SealedExpectations {
53    pub profile: String,
54    pub candidate_ref: String,
55    pub target_ref: String,
56    pub repository: RepositoryIdentity,
57    pub provider: String,
58    pub provider_run_id: String,
59    pub provider_run_attempt: u64,
60    pub candidate_identity_digest: String,
61    pub organization_floor: Option<SealedControlExpectation>,
62    pub debt_snapshot: Option<SealedControlExpectation>,
63    pub waiver_bundle: Option<SealedControlExpectation>,
64    pub execution_constraint: SealedControlExpectation,
65    pub trusted_time_digest: String,
66}
67
68/// One exact externally authenticated control projection expected in the
69/// report after the engine verifies its embedded semantic digest.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct SealedControlExpectation {
72    pub digest: String,
73    pub trust_source: String,
74}
75
76fn member<'value>(value: &'value Value, key: &str) -> Option<&'value Value> {
77    match value {
78        Value::Object(members) => members
79            .iter()
80            .find(|(name, _)| name == key)
81            .map(|(_, member)| member),
82        Value::Null | Value::Bool(_) | Value::Integer(_) | Value::String(_) | Value::Array(_) => {
83            None
84        }
85    }
86}
87
88fn text<'value>(value: &'value Value, key: &str) -> Option<&'value str> {
89    match member(value, key) {
90        Some(Value::String(text)) => Some(text),
91        _ => None,
92    }
93}
94
95/// The acceptance law: the wire is exactly `JCS(envelope) || LF`, the
96/// payload-only digest recomputes, the engine digest equals the validated
97/// binary's, the evaluated identities equal the ones requested, the
98/// completeness flag agrees with the exit class, and the finding count equals
99/// the findings array length. Text printed before a crash is never
100/// interpreted as a result. Success returns the envelope's exit class, so the
101/// wrapper can hold the engine process to it.
102///
103/// # Errors
104///
105/// The first applicable defect in the order above.
106pub fn accept(wire: &[u8], expectations: &Expectations) -> Result<i64, AcceptanceDefect> {
107    let trimmed = wire
108        .strip_suffix(b"\n")
109        .ok_or(AcceptanceDefect::Noncanonical)?;
110    let envelope = parse(trimmed).map_err(|_defect| AcceptanceDefect::Shape)?;
111    if canonical(&envelope) != trimmed {
112        return Err(AcceptanceDefect::Noncanonical);
113    }
114    if text(&envelope, "schema") != Some(ENVELOPE_SCHEMA) {
115        return Err(AcceptanceDefect::Shape);
116    }
117    let payload = member(&envelope, "payload").ok_or(AcceptanceDefect::Shape)?;
118    if text(payload, "schema") != Some(PAYLOAD_SCHEMA) {
119        return Err(AcceptanceDefect::Shape);
120    }
121    let recorded = text(&envelope, "payload_digest").ok_or(AcceptanceDefect::Shape)?;
122    if hj(PAYLOAD_SCHEMA, payload).to_string() != recorded {
123        return Err(AcceptanceDefect::PayloadDigest);
124    }
125    let engine_row = member(payload, "engine").ok_or(AcceptanceDefect::Shape)?;
126    if text(engine_row, "engine_digest") != Some(expectations.engine_digest.as_str()) {
127        return Err(AcceptanceDefect::Engine);
128    }
129    let evaluation = member(payload, "evaluation").ok_or(AcceptanceDefect::Shape)?;
130    let resolved = text(evaluation, "status") != Some("unavailable");
131    if expectations.sealed.is_some() && !resolved {
132        return Err(AcceptanceDefect::SealedIdentity);
133    }
134    if resolved {
135        let base = member(evaluation, "base").ok_or(AcceptanceDefect::Shape)?;
136        if text(base, "commit_oid") != Some(expectations.base_commit.as_str()) {
137            return Err(AcceptanceDefect::BaseIdentity);
138        }
139        let candidate = member(evaluation, "candidate").ok_or(AcceptanceDefect::Shape)?;
140        if let Some(expected) = expectations.candidate_commit.as_deref()
141            && (text(candidate, "kind") != Some("git-commit")
142                || text(candidate, "commit_oid") != Some(expected))
143        {
144            return Err(AcceptanceDefect::CandidateIdentity);
145        }
146        if let Some(sealed) = &expectations.sealed {
147            accept_sealed(payload, evaluation, sealed)?;
148        }
149    }
150    let result = member(payload, "result").ok_or(AcceptanceDefect::Shape)?;
151    let exit_code = match member(result, "exit_code") {
152        Some(Value::Integer(code)) => *code,
153        _ => return Err(AcceptanceDefect::Shape),
154    };
155    let complete = member(result, "complete") == Some(&Value::Bool(true));
156    if complete != (exit_code == 0 || exit_code == 1) {
157        return Err(AcceptanceDefect::Completeness);
158    }
159    let count = match member(result, "finding_count") {
160        Some(Value::Integer(count)) => *count,
161        _ => return Err(AcceptanceDefect::Shape),
162    };
163    let findings = match member(payload, "findings") {
164        Some(Value::Array(rows)) => rows.len(),
165        _ => return Err(AcceptanceDefect::Shape),
166    };
167    if i64::try_from(findings).map_err(|_defect| AcceptanceDefect::Shape)? != count {
168        return Err(AcceptanceDefect::FindingCount);
169    }
170    Ok(exit_code)
171}
172
173fn accept_sealed(
174    payload: &Value,
175    evaluation: &Value,
176    expected: &SealedExpectations,
177) -> Result<(), AcceptanceDefect> {
178    if text(evaluation, "candidate_ref") != Some(expected.candidate_ref.as_str())
179        || text(evaluation, "target_ref") != Some(expected.target_ref.as_str())
180        || member(evaluation, "trusted_time") != Some(&Value::Bool(true))
181    {
182        return Err(AcceptanceDefect::SealedIdentity);
183    }
184    let Value::Object(evaluation_members) = evaluation else {
185        return Err(AcceptanceDefect::Shape);
186    };
187    let mut identity_members: Vec<(String, Value)> = evaluation_members
188        .iter()
189        .filter(|(name, _value)| name != "evaluation_instant" && name != "trusted_time")
190        .cloned()
191        .collect();
192    identity_members.push((
193        "schema".to_owned(),
194        Value::String(CANDIDATE_IDENTITY_DOMAIN.to_owned()),
195    ));
196    let identity = Value::Object(identity_members);
197    let identity_digest = hj(CANDIDATE_IDENTITY_DOMAIN, &identity).to_string();
198    if identity_digest != expected.candidate_identity_digest {
199        return Err(AcceptanceDefect::SealedIdentity);
200    }
201
202    let controls = member(payload, "controls").ok_or(AcceptanceDefect::SealedControls)?;
203    if text(controls, "profile") != Some(expected.profile.as_str()) {
204        return Err(AcceptanceDefect::SealedControls);
205    }
206    accept_optional_control(
207        controls,
208        "organization_floor",
209        expected.organization_floor.as_ref(),
210    )?;
211    accept_optional_control(controls, "debt_snapshot", expected.debt_snapshot.as_ref())?;
212    accept_optional_control(controls, "waiver_bundle", expected.waiver_bundle.as_ref())?;
213    let constraint =
214        member(controls, "execution_constraint").ok_or(AcceptanceDefect::SealedControls)?;
215    let descriptor = member(constraint, "descriptor").ok_or(AcceptanceDefect::SealedControls)?;
216    let descriptor = ExecutionConstraintDescriptor::parse(&canonical(descriptor))
217        .map_err(|_defect| AcceptanceDefect::SealedControls)?;
218    if text(constraint, "status") != Some("verified")
219        || text(constraint, "descriptor_digest")
220            != Some(expected.execution_constraint.digest.as_str())
221        || text(constraint, "trust_source")
222            != Some(expected.execution_constraint.trust_source.as_str())
223        || descriptor.digest.to_string() != expected.execution_constraint.digest
224    {
225        return Err(AcceptanceDefect::SealedControls);
226    }
227    let trusted =
228        member(controls, "trusted_time_source").ok_or(AcceptanceDefect::SealedControls)?;
229    let statement = member(trusted, "statement").ok_or(AcceptanceDefect::SealedControls)?;
230    let statement = TrustedTimeStatement::parse(&canonical(statement))
231        .map_err(|_defect| AcceptanceDefect::SealedControls)?;
232    if text(trusted, "status") != Some("verified")
233        || text(trusted, "trust_source") != Some("external-required-check")
234        || text(trusted, "statement_digest") != Some(expected.trusted_time_digest.as_str())
235        || statement.digest.to_string() != expected.trusted_time_digest
236        || statement.provider != expected.provider
237        || statement.provider_run_id != expected.provider_run_id
238        || statement.provider_run_attempt != expected.provider_run_attempt
239        || statement.repository != expected.repository
240        || statement.ref_name.as_str() != expected.target_ref
241        || statement.candidate_identity_digest.to_string() != identity_digest
242        || text(evaluation, "evaluation_instant") != Some(statement.evaluation_instant.as_str())
243    {
244        return Err(AcceptanceDefect::SealedControls);
245    }
246    let sandbox = member(controls, "sandbox").ok_or(AcceptanceDefect::SealedControls)?;
247    if text(sandbox, "assurance") != Some("self-asserted")
248        || text(sandbox, "enforcement_source") != Some("local-process")
249        || member(sandbox, "verification") != Some(&Value::Null)
250    {
251        return Err(AcceptanceDefect::SealedControls);
252    }
253    Ok(())
254}
255
256fn accept_optional_control(
257    controls: &Value,
258    name: &str,
259    expected: Option<&SealedControlExpectation>,
260) -> Result<(), AcceptanceDefect> {
261    let control = member(controls, name).ok_or(AcceptanceDefect::SealedControls)?;
262    let accepted = match expected {
263        Some(expected) => {
264            text(control, "status") == Some("verified")
265                && text(control, "digest") == Some(expected.digest.as_str())
266                && text(control, "trust_source") == Some(expected.trust_source.as_str())
267        }
268        None => {
269            text(control, "status") == Some("none")
270                && member(control, "digest") == Some(&Value::Null)
271                && text(control, "trust_source") == Some("none")
272        }
273    };
274    if accepted {
275        Ok(())
276    } else {
277        Err(AcceptanceDefect::SealedControls)
278    }
279}
280
281/// The watchdog outcome for one spawned engine process.
282#[derive(Debug)]
283pub enum Supervised {
284    /// The engine exited on its own within the ceiling.
285    Completed(ExitStatus),
286    /// The ceiling passed; the engine was killed and reaped. A killed engine
287    /// yields no accepted envelope.
288    Killed,
289}
290
291/// The operational wall-time watchdog: polls the engine until it exits or the
292/// ceiling passes, then kills and reaps it. The kill can never produce a
293/// partial result whose presence depends on runner speed; the caller fails the
294/// run without an envelope.
295///
296/// # Errors
297///
298/// Only `try_wait` failures; kill and reap errors after a timeout are
299/// deliberately ignored because the outcome is already `Killed`.
300pub fn supervise(child: &mut Child, ceiling: Duration) -> std::io::Result<Supervised> {
301    let start = Instant::now();
302    loop {
303        if let Some(status) = child.try_wait()? {
304            return Ok(Supervised::Completed(status));
305        }
306        if start.elapsed() >= ceiling {
307            let _signalled = child.kill();
308            let _reaped = child.wait();
309            return Ok(Supervised::Killed);
310        }
311        std::thread::sleep(Duration::from_millis(25));
312    }
313}
314
315/// Why a run produced no accepted result. Every one of these is a failed
316/// required check, and none of them publishes an envelope: a report the
317/// wrapper cannot accept is not a report.
318#[derive(Clone, Copy, Debug, PartialEq, Eq)]
319pub enum Defect {
320    /// The engine outlived the wall ceiling and was killed.
321    Killed,
322    /// The engine died on a signal and carries no exit code.
323    Signalled,
324    /// The engine wrote more than the wire ceiling admits.
325    Oversize,
326    /// The engine's own exit code disagrees with the exit class it reported.
327    ExitMismatch,
328    /// The envelope failed the acceptance law.
329    Acceptance(AcceptanceDefect),
330}
331
332/// The settlement law, over what the wrapper can observe of a finished engine:
333/// its exit code and its complete stdout. An accepted envelope returns the
334/// exit class the wrapper then exits with, and which the engine's own process
335/// exit code must already equal. Nothing else is publishable.
336///
337/// # Errors
338///
339/// The defect that refused the result.
340pub fn settle(
341    outcome: &Supervised,
342    stdout: &[u8],
343    expectations: &Expectations,
344) -> Result<i64, Defect> {
345    let status = match *outcome {
346        Supervised::Killed => return Err(Defect::Killed),
347        Supervised::Completed(status) => status,
348    };
349    if u64::try_from(stdout.len()).unwrap_or(u64::MAX) > amiss_wire::report::MACHINE_JSON_BYTES {
350        return Err(Defect::Oversize);
351    }
352    let code = status.code().ok_or(Defect::Signalled)?;
353    let class = accept(stdout, expectations).map_err(Defect::Acceptance)?;
354    if i64::from(code) != class {
355        return Err(Defect::ExitMismatch);
356    }
357    Ok(class)
358}