Skip to main content

runx_runtime/execution/harness/
assertions.rs

1use runx_contracts::{ClosureDisposition, Receipt, ReceiptSchema};
2use runx_receipts::{
3    ReceiptProofContextProvider, canonical_receipt_body_digest, canonical_receipt_digest,
4    verify_receipt_proof,
5};
6
7use crate::execution::harness::fixtures::{HarnessExpectedStatus, ReceiptExpectation};
8use crate::execution::harness::runner::{HarnessReplayError, HarnessReplayOutput};
9use crate::receipts::{RuntimeReceiptProofContextProvider, RuntimeReceiptSignaturePolicy};
10
11#[derive(Clone, Debug, PartialEq)]
12pub struct HarnessReplayReceipt {
13    pub receipt_id: String,
14    pub harness_id: String,
15    pub state: String,
16    pub disposition: ClosureDisposition,
17    pub reason_code: String,
18    pub act_ids: Vec<String>,
19    pub decision_ids: Vec<String>,
20    pub child_receipt_refs: Vec<String>,
21    pub verification_refs: Vec<String>,
22}
23
24pub(super) fn assert_expectations(
25    output: &HarnessReplayOutput,
26    signature_policy: RuntimeReceiptSignaturePolicy<'_>,
27) -> Result<(), HarnessReplayError> {
28    if let Some(expected_status) = &output.fixture.expect.status {
29        assert_equal(
30            "expect.status",
31            status_name(expected_status),
32            status_name(&output.status),
33        )?;
34    }
35    if let Some(expected_receipt) = &output.fixture.expect.receipt {
36        assert_receipt(expected_receipt, &output.receipt, signature_policy)?;
37    }
38    if !output.fixture.expect.steps.is_empty() {
39        let actual = output
40            .step_receipts
41            .iter()
42            .map(receipt_step_name)
43            .collect::<Vec<_>>();
44        assert_equal(
45            "expect.steps",
46            output.fixture.expect.steps.join(","),
47            actual.join(","),
48        )?;
49    }
50    Ok(())
51}
52
53pub(super) fn status_from_disposition(disposition: &ClosureDisposition) -> HarnessExpectedStatus {
54    match disposition {
55        ClosureDisposition::Closed => HarnessExpectedStatus::Sealed,
56        ClosureDisposition::Deferred => HarnessExpectedStatus::NeedsAgent,
57        ClosureDisposition::Blocked => HarnessExpectedStatus::PolicyDenied,
58        ClosureDisposition::TimedOut
59        | ClosureDisposition::Declined
60        | ClosureDisposition::Failed
61        | ClosureDisposition::Killed
62        | ClosureDisposition::Superseded => HarnessExpectedStatus::Failure,
63    }
64}
65
66// The RUNX_REGEN_FIXTURES branch prints regenerated digests to stderr so a
67// human can paste them back into fixtures; it is a developer regen path, not
68// runtime logging, so the workspace print ban is lifted for this function only.
69#[allow(clippy::print_stderr)]
70fn assert_receipt(
71    expected: &ReceiptExpectation,
72    actual: &Receipt,
73    signature_policy: RuntimeReceiptSignaturePolicy<'_>,
74) -> Result<(), HarnessReplayError> {
75    assert_receipt_proof(actual, signature_policy)?;
76    if crate::services::process_env_value("RUNX_REGEN_FIXTURES").is_some() {
77        let summary = summarize_receipt(actual);
78        let body_digest = canonical_receipt_body_digest(actual).map_err(receipt_digest_error)?;
79        let receipt_digest = canonical_receipt_digest(actual).map_err(receipt_digest_error)?;
80        eprintln!(
81            "REGEN id={} receipt_id={} body_digest={} receipt_digest={} child_refs=[{}]",
82            actual.subject.reference.uri,
83            actual.id,
84            body_digest,
85            receipt_digest,
86            summary.child_receipt_refs.join(",")
87        );
88        return Ok(());
89    }
90    assert_equal(
91        "expect.receipt.schema",
92        schema_name(&expected.schema),
93        schema_name(&actual.schema),
94    )?;
95    if let Some(expected_id) = &expected.receipt_id {
96        assert_equal("expect.receipt.receipt_id", expected_id, &actual.id)?;
97    }
98    let summary = summarize_receipt(actual);
99    assert_receipt_identity(expected, &summary)?;
100    assert_receipt_lists(expected, &summary)?;
101    assert_receipt_digests(expected, actual)
102}
103
104fn assert_receipt_identity(
105    expected: &ReceiptExpectation,
106    summary: &HarnessReplayReceipt,
107) -> Result<(), HarnessReplayError> {
108    if let Some(expected_harness_id) = &expected.harness_id {
109        assert_equal(
110            "expect.receipt.harness_id",
111            expected_harness_id,
112            &summary.harness_id,
113        )?;
114    }
115    if let Some(expected_state) = &expected.state {
116        assert_equal(
117            "expect.receipt.state",
118            expected_state.as_str(),
119            summary.state.as_str(),
120        )?;
121    }
122    if let Some(expected_disposition) = &expected.disposition {
123        assert_equal(
124            "expect.receipt.disposition",
125            disposition_name(expected_disposition),
126            disposition_name(&summary.disposition),
127        )?;
128    }
129    if let Some(expected_reason_code) = &expected.reason_code {
130        assert_equal(
131            "expect.receipt.reason_code",
132            expected_reason_code,
133            &summary.reason_code,
134        )?;
135    }
136    Ok(())
137}
138
139fn assert_receipt_lists(
140    expected: &ReceiptExpectation,
141    summary: &HarnessReplayReceipt,
142) -> Result<(), HarnessReplayError> {
143    assert_optional_list(
144        "expect.receipt.act_ids",
145        &expected.act_ids,
146        &summary.act_ids,
147    )?;
148    assert_optional_list(
149        "expect.receipt.decision_ids",
150        &expected.decision_ids,
151        &summary.decision_ids,
152    )?;
153    assert_optional_list(
154        "expect.receipt.child_receipt_refs",
155        &expected.child_receipt_refs,
156        &summary.child_receipt_refs,
157    )?;
158    assert_optional_list(
159        "expect.receipt.verification_refs",
160        &expected.verification_refs,
161        &summary.verification_refs,
162    )
163}
164
165fn assert_receipt_digests(
166    expected: &ReceiptExpectation,
167    actual: &Receipt,
168) -> Result<(), HarnessReplayError> {
169    if let Some(expected_body_digest) = &expected.body_digest {
170        let body_digest = canonical_receipt_body_digest(actual).map_err(receipt_digest_error)?;
171        assert_equal(
172            "expect.receipt.body_digest",
173            expected_body_digest,
174            body_digest,
175        )?;
176    }
177    if let Some(expected_digest) = &expected.receipt_digest {
178        let receipt_digest = canonical_receipt_digest(actual).map_err(receipt_digest_error)?;
179        assert_equal(
180            "expect.receipt.receipt_digest",
181            expected_digest,
182            receipt_digest,
183        )?;
184    }
185    Ok(())
186}
187
188fn assert_receipt_proof(
189    receipt: &Receipt,
190    signature_policy: RuntimeReceiptSignaturePolicy<'_>,
191) -> Result<(), HarnessReplayError> {
192    let proof_contexts = RuntimeReceiptProofContextProvider::new(signature_policy);
193    let context = proof_contexts.proof_context(receipt);
194    let verification = verify_receipt_proof(receipt, &context);
195    if verification.valid {
196        Ok(())
197    } else {
198        Err(HarnessReplayError::ReceiptProofInvalid {
199            receipt_id: receipt.id.to_string(),
200            findings: format!("{:?}", verification.findings),
201        })
202    }
203}
204
205fn receipt_digest_error(error: runx_receipts::ReceiptError) -> HarnessReplayError {
206    HarnessReplayError::ReceiptDigest {
207        message: error.to_string(),
208    }
209}
210
211fn summarize_receipt(receipt: &Receipt) -> HarnessReplayReceipt {
212    let state = if matches!(receipt.seal.disposition, ClosureDisposition::Deferred) {
213        "deferred".to_owned()
214    } else {
215        "sealed".to_owned()
216    };
217    HarnessReplayReceipt {
218        receipt_id: receipt.id.to_string(),
219        harness_id: receipt.subject.reference.uri.clone().into_string(),
220        state,
221        disposition: receipt.seal.disposition.clone(),
222        reason_code: receipt.seal.reason_code.to_string(),
223        act_ids: receipt.acts.iter().map(|act| act.id.to_string()).collect(),
224        decision_ids: receipt
225            .decisions
226            .iter()
227            .map(|decision| decision.decision_id.as_str().to_owned())
228            .collect(),
229        child_receipt_refs: receipt
230            .lineage
231            .as_ref()
232            .map(|lineage| {
233                lineage
234                    .children
235                    .iter()
236                    .map(|reference| reference.uri.clone().into_string())
237                    .collect()
238            })
239            .unwrap_or_default(),
240        verification_refs: receipt
241            .acts
242            .iter()
243            .flat_map(|act| act.criterion_bindings.iter())
244            .flat_map(|binding| binding.verification_refs.iter())
245            .map(|reference| reference.uri.clone().into_string())
246            .collect(),
247    }
248}
249
250fn receipt_step_name(receipt: &Receipt) -> String {
251    receipt.acts.first().map_or_else(
252        || receipt.subject.reference.uri.clone().into_string(),
253        |act| act.id.trim_start_matches("act_").to_owned(),
254    )
255}
256
257fn assert_optional_list(
258    field: &'static str,
259    expected: &[String],
260    actual: &[String],
261) -> Result<(), HarnessReplayError> {
262    if expected.is_empty() {
263        return Ok(());
264    }
265    assert_equal(field, expected.join(","), actual.join(","))
266}
267
268fn assert_equal(
269    field: &'static str,
270    expected: impl AsRef<str>,
271    actual: impl AsRef<str>,
272) -> Result<(), HarnessReplayError> {
273    let expected = expected.as_ref();
274    let actual = actual.as_ref();
275    if expected == actual {
276        return Ok(());
277    }
278    Err(HarnessReplayError::Mismatch {
279        field,
280        expected: expected.to_owned(),
281        actual: actual.to_owned(),
282    })
283}
284
285fn schema_name(schema: &ReceiptSchema) -> &'static str {
286    match schema {
287        ReceiptSchema::V1 => "runx.receipt.v1",
288    }
289}
290
291fn disposition_name(disposition: &ClosureDisposition) -> &'static str {
292    match disposition {
293        ClosureDisposition::Closed => "closed",
294        ClosureDisposition::Deferred => "deferred",
295        ClosureDisposition::Superseded => "superseded",
296        ClosureDisposition::Declined => "declined",
297        ClosureDisposition::Blocked => "blocked",
298        ClosureDisposition::Failed => "failed",
299        ClosureDisposition::Killed => "killed",
300        ClosureDisposition::TimedOut => "timed_out",
301    }
302}
303
304fn status_name(status: &HarnessExpectedStatus) -> &'static str {
305    match status {
306        HarnessExpectedStatus::Sealed => "sealed",
307        HarnessExpectedStatus::Failure => "failure",
308        HarnessExpectedStatus::NeedsAgent => "needs_agent",
309        HarnessExpectedStatus::PolicyDenied => "policy_denied",
310        HarnessExpectedStatus::Escalated => "escalated",
311    }
312}