Skip to main content

chio_listing/outcome/
predicate.rs

1use std::collections::BTreeSet;
2
3use chio_core_types::canonical::canonical_json_bytes_from_str;
4use chio_core_types::crypto::{sha256_hex, Keypair};
5use chio_core_types::receipt::lineage::SignedExportEnvelope;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use super::{
10    canonical_outcome_bytes, domain_digest, envelope_digest, load_canonical_outcome_json,
11    validate_current_window, validate_digest, validate_text, validate_window, OutcomeError,
12    OutcomeSignerTrustV1,
13};
14
15pub const OUTCOME_PREDICATE_SCHEMA: &str = chio_core_types::CHIO_OUTCOME_PREDICATE_V1_SCHEMA;
16
17const PREDICATE_ID_DOMAIN: &[u8] = b"chio.outcome.predicate.id.v1\0";
18const PREDICATE_BODY_DIGEST_DOMAIN: &[u8] = b"chio.outcome.predicate.body.v1\0";
19const MAX_ASSERTIONS: usize = 256;
20const MAX_POINTER_CHARS: usize = 2_048;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
24pub enum OutcomeComparatorV1 {
25    Exists,
26    Eq { value: Value },
27    Ne { value: Value },
28    Lt { value: Value },
29    Lte { value: Value },
30    Gt { value: Value },
31    Gte { value: Value },
32}
33
34impl OutcomeComparatorV1 {
35    fn validate(&self) -> Result<(), OutcomeError> {
36        match self {
37            Self::Exists | Self::Eq { .. } | Self::Ne { .. } => Ok(()),
38            Self::Lt { value } | Self::Lte { value } | Self::Gt { value } | Self::Gte { value } => {
39                integer(value)
40                    .map(|_| ())
41                    .ok_or(OutcomeError::InvalidField("ordered_comparator_value"))
42            }
43        }
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase", deny_unknown_fields)]
49pub struct OutcomeAssertionV1 {
50    pub pointer: String,
51    pub comparator: OutcomeComparatorV1,
52}
53
54impl OutcomeAssertionV1 {
55    fn validate(&self) -> Result<(), OutcomeError> {
56        validate_pointer(&self.pointer)?;
57        self.comparator.validate()
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct OutcomePredicateInputV1 {
63    pub assertions: Vec<OutcomeAssertionV1>,
64    pub provider_id: String,
65    pub issued_at_unix_ms: u64,
66    pub expires_at_unix_ms: u64,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase", deny_unknown_fields)]
71pub struct OutcomePredicateBodyV1 {
72    schema: String,
73    predicate_id: String,
74    assertions: Vec<OutcomeAssertionV1>,
75    provider_id: String,
76    issued_at_unix_ms: u64,
77    expires_at_unix_ms: u64,
78}
79
80#[derive(Serialize)]
81#[serde(rename_all = "camelCase")]
82struct PredicateIdPreimage<'a> {
83    schema: &'a str,
84    assertions: &'a [OutcomeAssertionV1],
85    provider_id: &'a str,
86    issued_at_unix_ms: u64,
87    expires_at_unix_ms: u64,
88}
89
90impl OutcomePredicateBodyV1 {
91    pub fn new(input: OutcomePredicateInputV1) -> Result<Self, OutcomeError> {
92        let mut body = Self {
93            schema: OUTCOME_PREDICATE_SCHEMA.to_owned(),
94            predicate_id: String::new(),
95            assertions: input.assertions,
96            provider_id: input.provider_id,
97            issued_at_unix_ms: input.issued_at_unix_ms,
98            expires_at_unix_ms: input.expires_at_unix_ms,
99        };
100        body.predicate_id = body.derived_id()?;
101        body.validate()?;
102        Ok(body)
103    }
104
105    pub fn validate(&self) -> Result<(), OutcomeError> {
106        if self.schema != OUTCOME_PREDICATE_SCHEMA {
107            return Err(OutcomeError::InvalidField("predicate_schema"));
108        }
109        validate_digest("predicate_id", &self.predicate_id)?;
110        validate_text("provider_id", &self.provider_id)?;
111        validate_window(self.issued_at_unix_ms, self.expires_at_unix_ms)?;
112        if self.assertions.is_empty() || self.assertions.len() > MAX_ASSERTIONS {
113            return Err(OutcomeError::InvalidField("assertions"));
114        }
115        let mut unique = BTreeSet::new();
116        for assertion in &self.assertions {
117            assertion.validate()?;
118            if !unique.insert(canonical_outcome_bytes(assertion)?) {
119                return Err(OutcomeError::InvalidField("duplicate_assertion"));
120            }
121        }
122        if self.predicate_id != self.derived_id()? {
123            return Err(OutcomeError::BindingMismatch);
124        }
125        Ok(())
126    }
127
128    fn derived_id(&self) -> Result<String, OutcomeError> {
129        domain_digest(
130            PREDICATE_ID_DOMAIN,
131            &PredicateIdPreimage {
132                schema: &self.schema,
133                assertions: &self.assertions,
134                provider_id: &self.provider_id,
135                issued_at_unix_ms: self.issued_at_unix_ms,
136                expires_at_unix_ms: self.expires_at_unix_ms,
137            },
138        )
139    }
140
141    #[must_use]
142    pub fn predicate_id(&self) -> &str {
143        &self.predicate_id
144    }
145
146    #[must_use]
147    pub fn provider_id(&self) -> &str {
148        &self.provider_id
149    }
150
151    #[must_use]
152    pub fn assertions(&self) -> &[OutcomeAssertionV1] {
153        &self.assertions
154    }
155
156    #[must_use]
157    pub const fn expires_at_unix_ms(&self) -> u64 {
158        self.expires_at_unix_ms
159    }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(transparent)]
164pub struct SignedOutcomePredicateV1(SignedExportEnvelope<OutcomePredicateBodyV1>);
165
166impl SignedOutcomePredicateV1 {
167    pub fn sign(body: OutcomePredicateBodyV1, signer: &Keypair) -> Result<Self, OutcomeError> {
168        body.validate()?;
169        SignedExportEnvelope::sign(body, signer)
170            .map(Self)
171            .map_err(|error| OutcomeError::Canonicalization(error.to_string()))
172    }
173
174    #[must_use]
175    pub const fn body(&self) -> &OutcomePredicateBodyV1 {
176        &self.0.body
177    }
178}
179
180pub struct OutcomePredicateVerificationV1<'a> {
181    pub provider_id: &'a str,
182    pub trust: &'a OutcomeSignerTrustV1,
183    pub trusted_now_unix_ms: u64,
184}
185
186#[derive(Debug, Clone)]
187pub struct VerifiedOutcomePredicateV1 {
188    signed: SignedOutcomePredicateV1,
189    body_digest: String,
190    envelope_digest: String,
191}
192
193impl VerifiedOutcomePredicateV1 {
194    #[must_use]
195    pub const fn body(&self) -> &OutcomePredicateBodyV1 {
196        self.signed.body()
197    }
198
199    #[must_use]
200    pub fn body_digest(&self) -> &str {
201        &self.body_digest
202    }
203
204    #[must_use]
205    pub fn envelope_digest(&self) -> &str {
206        &self.envelope_digest
207    }
208}
209
210pub fn verify_outcome_predicate(
211    canonical_envelope: &[u8],
212    context: &OutcomePredicateVerificationV1<'_>,
213) -> Result<VerifiedOutcomePredicateV1, OutcomeError> {
214    let signed: SignedOutcomePredicateV1 = load_canonical_outcome_json(canonical_envelope)?;
215    signed.body().validate()?;
216    if signed.body().provider_id != context.provider_id
217        || signed.body().provider_id != context.trust.principal_id()
218    {
219        return Err(OutcomeError::BindingMismatch);
220    }
221    if signed.0.signer_key != *context.trust.key()
222        || !signed
223            .0
224            .verify_signature()
225            .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?
226    {
227        return Err(OutcomeError::AuthorityVerification);
228    }
229    validate_current_window(
230        signed.body().issued_at_unix_ms,
231        signed.body().expires_at_unix_ms,
232        context.trust.max_lifetime_ms(),
233        context.trusted_now_unix_ms,
234    )?;
235    Ok(VerifiedOutcomePredicateV1 {
236        body_digest: domain_digest(PREDICATE_BODY_DIGEST_DOMAIN, signed.body())?,
237        envelope_digest: envelope_digest(&signed)?,
238        signed,
239    })
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case")]
244pub enum OutcomeEvaluationReasonV1 {
245    AssertionMismatch,
246    MissingTarget,
247    TargetNotInteger,
248    InvalidOutputJson,
249    DeliveryCancelled,
250    OutputBlocked,
251    OutputMutationAfterEvaluation,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(tag = "verdict", rename_all = "snake_case", deny_unknown_fields)]
256pub enum OutcomeEvaluationV1 {
257    Passed,
258    Failed {
259        assertion_index: u32,
260        reason: OutcomeEvaluationReasonV1,
261    },
262    Unevaluable {
263        reason: OutcomeEvaluationReasonV1,
264    },
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct VerifiedOutcomeEvaluationV1 {
269    evaluation: OutcomeEvaluationV1,
270    output_digest: String,
271    predicate_id: String,
272    predicate_digest: String,
273}
274
275impl VerifiedOutcomeEvaluationV1 {
276    #[must_use]
277    pub const fn evaluation(&self) -> &OutcomeEvaluationV1 {
278        &self.evaluation
279    }
280
281    #[must_use]
282    pub fn output_digest(&self) -> &str {
283        &self.output_digest
284    }
285
286    #[must_use]
287    pub fn predicate_id(&self) -> &str {
288        &self.predicate_id
289    }
290
291    #[must_use]
292    pub fn predicate_digest(&self) -> &str {
293        &self.predicate_digest
294    }
295}
296
297pub fn evaluate_outcome_predicate(
298    predicate: &VerifiedOutcomePredicateV1,
299    output: &[u8],
300) -> VerifiedOutcomeEvaluationV1 {
301    VerifiedOutcomeEvaluationV1 {
302        evaluation: evaluate(predicate, output),
303        output_digest: sha256_hex(output),
304        predicate_id: predicate.body().predicate_id().to_owned(),
305        predicate_digest: predicate.envelope_digest().to_owned(),
306    }
307}
308
309fn evaluate(predicate: &VerifiedOutcomePredicateV1, output: &[u8]) -> OutcomeEvaluationV1 {
310    let Ok(output_text) = std::str::from_utf8(output) else {
311        return OutcomeEvaluationV1::Unevaluable {
312            reason: OutcomeEvaluationReasonV1::InvalidOutputJson,
313        };
314    };
315    if canonical_json_bytes_from_str(output_text).is_err() {
316        return OutcomeEvaluationV1::Unevaluable {
317            reason: OutcomeEvaluationReasonV1::InvalidOutputJson,
318        };
319    }
320    let document: Value = match serde_json::from_slice(output) {
321        Ok(document) => document,
322        Err(_) => {
323            return OutcomeEvaluationV1::Unevaluable {
324                reason: OutcomeEvaluationReasonV1::InvalidOutputJson,
325            };
326        }
327    };
328    for (index, assertion) in predicate.body().assertions.iter().enumerate() {
329        let Some(target) = select_pointer(&document, &assertion.pointer) else {
330            return OutcomeEvaluationV1::Failed {
331                assertion_index: u32::try_from(index).unwrap_or(u32::MAX),
332                reason: OutcomeEvaluationReasonV1::MissingTarget,
333            };
334        };
335        let passed = match &assertion.comparator {
336            OutcomeComparatorV1::Exists => true,
337            OutcomeComparatorV1::Eq { value } => canonical_equal(target, value),
338            OutcomeComparatorV1::Ne { value } => !canonical_equal(target, value),
339            OutcomeComparatorV1::Lt { value }
340            | OutcomeComparatorV1::Lte { value }
341            | OutcomeComparatorV1::Gt { value }
342            | OutcomeComparatorV1::Gte { value } => {
343                let Some(left) = integer(target) else {
344                    return OutcomeEvaluationV1::Unevaluable {
345                        reason: OutcomeEvaluationReasonV1::TargetNotInteger,
346                    };
347                };
348                let Some(right) = integer(value) else {
349                    return OutcomeEvaluationV1::Unevaluable {
350                        reason: OutcomeEvaluationReasonV1::TargetNotInteger,
351                    };
352                };
353                match &assertion.comparator {
354                    OutcomeComparatorV1::Lt { .. } => left < right,
355                    OutcomeComparatorV1::Lte { .. } => left <= right,
356                    OutcomeComparatorV1::Gt { .. } => left > right,
357                    OutcomeComparatorV1::Gte { .. } => left >= right,
358                    _ => false,
359                }
360            }
361        };
362        if !passed {
363            return OutcomeEvaluationV1::Failed {
364                assertion_index: u32::try_from(index).unwrap_or(u32::MAX),
365                reason: OutcomeEvaluationReasonV1::AssertionMismatch,
366            };
367        }
368    }
369    OutcomeEvaluationV1::Passed
370}
371
372fn validate_pointer(pointer: &str) -> Result<(), OutcomeError> {
373    if pointer.is_empty() {
374        return Ok(());
375    }
376    if pointer.chars().count() > MAX_POINTER_CHARS
377        || !pointer.starts_with('/')
378        || pointer.chars().any(char::is_control)
379    {
380        return Err(OutcomeError::InvalidField("pointer"));
381    }
382    let bytes = pointer.as_bytes();
383    let mut index = 0;
384    while index < bytes.len() {
385        if bytes[index] == b'~' {
386            if index + 1 >= bytes.len() || !matches!(bytes[index + 1], b'0' | b'1') {
387                return Err(OutcomeError::InvalidField("pointer_escape"));
388            }
389            index += 2;
390        } else {
391            index += 1;
392        }
393    }
394    Ok(())
395}
396
397fn select_pointer<'a>(document: &'a Value, pointer: &str) -> Option<&'a Value> {
398    if pointer.is_empty() {
399        return Some(document);
400    }
401    let mut current = document;
402    for token in pointer.split('/').skip(1) {
403        let token = token.replace("~1", "/").replace("~0", "~");
404        current = match current {
405            Value::Object(object) => object.get(&token)?,
406            Value::Array(array) => {
407                let bytes = token.as_bytes();
408                let valid_index = token == "0"
409                    || bytes.first().is_some_and(|first| {
410                        first.is_ascii_digit()
411                            && *first != b'0'
412                            && bytes[1..].iter().all(u8::is_ascii_digit)
413                    });
414                if !valid_index {
415                    return None;
416                }
417                array.get(token.parse::<usize>().ok()?)?
418            }
419            _ => return None,
420        };
421    }
422    Some(current)
423}
424
425fn integer(value: &Value) -> Option<i128> {
426    value
427        .as_i64()
428        .map(i128::from)
429        .or_else(|| value.as_u64().map(i128::from))
430}
431
432fn canonical_equal(left: &Value, right: &Value) -> bool {
433    match (
434        canonical_outcome_bytes(left),
435        canonical_outcome_bytes(right),
436    ) {
437        (Ok(left), Ok(right)) => left == right,
438        _ => false,
439    }
440}