Skip to main content

confium_signatif/
conditions.rs

1//! Executable scope conditions (SIGNATIF §11 `scope-conditions`).
2//!
3//! A scope may carry **scope conditions** — executable predicates
4//! evaluated at verification time against the content and context of
5//! the artifact. An artifact signed by a key whose scope conditions
6//! are not met fails verification, *regardless of cryptographic
7//! signature validity* — this is one of the pipeline's hard checks.
8//!
9//! The expression language is a deterministic subset of JSON Logic
10//! (the Annex E reference choice): `var` paths over the evaluation
11//! context, numeric/string comparisons, and boolean combinators.
12//! Determinism (§11 `condition-determinism`): the evaluation context
13//! is fully determined by the artifact and its chain — payload,
14//! artifact id, signer reference, dimension, and the signer's
15//! attestation timestamp. No wall-clock, no external state.
16
17use serde_json::Value;
18
19use crate::error::{SignatifError, SignatifResult};
20
21/// The evaluation context for scope conditions: everything the
22/// artifact and its chain determine, and nothing else.
23#[derive(Debug, Clone)]
24pub struct ConditionContext {
25    /// The JSON evaluation root: `payload`, `artifact_id`,
26    /// `signer_cert_ref`, `dimension`, and `attested_at` (RFC 3339).
27    pub root: Value,
28}
29
30impl ConditionContext {
31    /// Build the context from an artifact's facts.
32    ///
33    /// # Errors
34    ///
35    /// Never fails; kept symmetrical with the other constructors.
36    pub fn new(
37        payload: &Value,
38        artifact_id: &str,
39        signer_cert_ref: &str,
40        dimension: &str,
41        attested_at: &str,
42    ) -> Self {
43        Self {
44            root: serde_json::json!({
45                "payload": payload,
46                "artifact_id": artifact_id,
47                "signer_cert_ref": signer_cert_ref,
48                "dimension": dimension,
49                "attested_at": attested_at,
50            }),
51        }
52    }
53}
54
55/// Evaluate one condition expression against a context.
56///
57/// Supported operators (JSON Logic subset):
58/// `{">=": [a, b]}`, `>`, `<=`, `<`, `{"==": [a, b]}`, `!=`,
59/// `{"and": [...]}`, `{"or": [...]}`, `{"!": expr}`, `{"!!": expr}`,
60/// `{"var": "dotted.path"}`. Numbers compare numerically; mixed
61/// number/string compares equal only on identical string forms;
62/// anything else compares by `Value` equality.
63///
64/// # Errors
65///
66/// Encoding errors for malformed expressions (unknown operator, bad
67/// arity, non-string `var` path). Malformed expressions are errors,
68/// not `false` — a condition that cannot be evaluated fails closed.
69pub fn evaluate_condition(expr: &Value, ctx: &ConditionContext) -> SignatifResult<bool> {
70    let obj = expr
71        .as_object()
72        .ok_or_else(|| SignatifError::Encoding("condition must be an object".into()))?;
73    let (op, args) = obj
74        .iter()
75        .next()
76        .ok_or_else(|| SignatifError::Encoding("condition object is empty".into()))?;
77    match op.as_str() {
78        "var" => Err(SignatifError::Encoding(
79            "var is only valid as an operand, not a top-level condition".into(),
80        )),
81        "!" => Ok(!evaluate_condition(args, ctx)?),
82        "!!" => evaluate_condition(args, ctx),
83        "and" => {
84            let list = args
85                .as_array()
86                .ok_or_else(|| SignatifError::Encoding("and expects an array".into()))?;
87            let mut ok = true;
88            for sub in list {
89                ok = ok && evaluate_condition(sub, ctx)?;
90            }
91            Ok(ok)
92        }
93        "or" => {
94            let list = args
95                .as_array()
96                .ok_or_else(|| SignatifError::Encoding("or expects an array".into()))?;
97            let mut ok = false;
98            for sub in list {
99                ok = ok || evaluate_condition(sub, ctx)?;
100            }
101            Ok(ok)
102        }
103        ">=" | ">" | "<=" | "<" | "==" | "!=" => {
104            let list = args
105                .as_array()
106                .ok_or_else(|| SignatifError::Encoding(format!("{op} expects an array")))?;
107            if list.len() != 2 {
108                return Err(SignatifError::Encoding(format!(
109                    "{op} expects two operands"
110                )));
111            }
112            let a = resolve_operand(&list[0], ctx)?;
113            let b = resolve_operand(&list[1], ctx)?;
114            let cmp = compare(&a, &b);
115            Ok(match op.as_str() {
116                ">=" => cmp != std::cmp::Ordering::Less,
117                ">" => cmp == std::cmp::Ordering::Greater,
118                "<=" => cmp != std::cmp::Ordering::Greater,
119                "<" => cmp == std::cmp::Ordering::Less,
120                "==" => cmp == std::cmp::Ordering::Equal,
121                _ => cmp != std::cmp::Ordering::Equal,
122            })
123        }
124        other => Err(SignatifError::Encoding(format!(
125            "unsupported condition operator {other}"
126        ))),
127    }
128}
129
130/// Evaluate every condition; all must hold.
131///
132/// # Errors
133///
134/// Propagates the first malformed or failing condition's error. A
135/// failing condition is an [`SignatifError::HardCheck`].
136pub fn evaluate_all(conditions: &[Value], ctx: &ConditionContext) -> SignatifResult<()> {
137    for (i, expr) in conditions.iter().enumerate() {
138        let ok = evaluate_condition(expr, ctx)?;
139        if !ok {
140            return Err(SignatifError::HardCheck(format!(
141                "scope_condition[{i}] not satisfied"
142            )));
143        }
144    }
145    Ok(())
146}
147
148fn resolve_operand(v: &Value, ctx: &ConditionContext) -> SignatifResult<Value> {
149    match v {
150        Value::Object(obj) => {
151            if let Some(Value::String(path)) = obj.get("var") {
152                let mut current = &ctx.root;
153                for segment in path.split('.') {
154                    current = current.get(segment).ok_or_else(|| {
155                        SignatifError::Encoding(format!("var path `{path}` not found"))
156                    })?;
157                }
158                Ok(current.clone())
159            } else {
160                // Nested expression.
161                Ok(Value::Bool(evaluate_condition(v, ctx)?))
162            }
163        }
164        other => Ok(other.clone()),
165    }
166}
167
168fn compare(a: &Value, b: &Value) -> std::cmp::Ordering {
169    if let (Some(x), Some(y)) = (a.as_f64(), b.as_f64()) {
170        return x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal);
171    }
172    if let (Some(x), Some(y)) = (a.as_str(), b.as_str()) {
173        return x.cmp(y);
174    }
175    if a == b {
176        std::cmp::Ordering::Equal
177    } else {
178        // Incomparable, unequal values sort greater to make == false
179        // and != true deterministically.
180        std::cmp::Ordering::Greater
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use serde_json::json;
188
189    fn ctx() -> ConditionContext {
190        ConditionContext::new(
191            &json!({"quantity": 50000, "product": "vaccine-batch-A", "batch": {"id": "LOT-1"}}),
192            "art-1",
193            "end-1",
194            "data",
195            "2026-08-18T00:00:00Z",
196        )
197    }
198
199    #[test]
200    fn value_within_range() {
201        let c = evaluate_condition(&json!({">=": [{"var": "payload.quantity"}, 10000]}), &ctx())
202            .unwrap();
203        assert!(c);
204        let c = evaluate_condition(&json!({"<": [{"var": "payload.quantity"}, 10000]}), &ctx())
205            .unwrap();
206        assert!(!c);
207    }
208
209    #[test]
210    fn nested_paths_and_boolean_combinators() {
211        let c = evaluate_condition(
212            &json!({"and": [
213                {"==": [{"var": "payload.product"}, "vaccine-batch-A"]},
214                {"or": [
215                    {">=": [{"var": "payload.quantity"}, 10000]},
216                    {"==": [{"var": "payload.batch.id"}, "LOT-9"]}
217                ]},
218            ]}),
219            &ctx(),
220        )
221        .unwrap();
222        assert!(c);
223    }
224
225    #[test]
226    fn signer_and_dimension_visible() {
227        let c = evaluate_condition(
228            &json!({"==": [{"var": "signer_cert_ref"}, "end-1"]}),
229            &ctx(),
230        )
231        .unwrap();
232        assert!(c);
233        let c = evaluate_condition(&json!({"==": [{"var": "dimension"}, "data"]}), &ctx()).unwrap();
234        assert!(c);
235    }
236
237    #[test]
238    fn failing_condition_hard_fails() {
239        let expr = serde_json::json!({ ">=": [ {"var": "payload.quantity"}, 999999 ] });
240        let err = evaluate_all(&[expr], &ctx()).unwrap_err();
241        assert!(err.to_string().contains("scope_condition[0]"));
242    }
243
244    #[test]
245    fn malformed_expressions_fail_closed() {
246        assert!(evaluate_condition(&json!("nope"), &ctx()).is_err());
247        assert!(evaluate_condition(&json!({"+": [1, 1]}), &ctx()).is_err());
248        assert!(
249            evaluate_condition(&json!({">=": [{"var": "payload.missing"}, 1]}), &ctx()).is_err()
250        );
251    }
252
253    #[test]
254    fn deterministic_same_context_same_result() {
255        let expr = json!({">=": [{"var": "payload.quantity"}, 1]});
256        assert_eq!(
257            evaluate_condition(&expr, &ctx()).unwrap(),
258            evaluate_condition(&expr, &ctx()).unwrap()
259        );
260    }
261}