Skip to main content

openvet_policy/
eval.rs

1//! Subject-level evaluation.
2//!
3//! Picks the effective requirement set for a subject (defaults
4//! plus overrides), runs each requirement's expression against
5//! every audit with alias-aware claim lookup, and collapses the
6//! per-(requirement, audit) tri-state grid into a single
7//! pass-or-fail verdict per subject.
8//!
9//! Per-requirement collapse:
10//!
11//! - `Pass` iff at least one audit evaluates to `True` and no
12//!   audit evaluates to `False`.
13//! - `Fail` otherwise. Distinguished as `NotAsserted` (every audit
14//!   was `Unknown`) or `Contradicted` (at least one audit evaluated
15//!   to `False`).
16//!
17//! Per-subject collapse: every effective requirement must pass.
18
19use crate::config::{Alias, OverrideOp, Policy, Requirement, SubjectMatcher};
20use crate::expr::{self, Expr, Tri};
21use openvet_crypto::TaggedHash;
22use openvet_proto::{Subject, audit::Audit};
23use std::fmt;
24
25/// Outcome of evaluating a [`Policy`] for one subject.
26///
27/// `Pass` means every effective requirement passed; `Fail` carries
28/// one [`FailureReason`] per requirement that didn't; `Unaudited`
29/// means no audit applies to this subject, so the per-requirement
30/// breakdown would be uniformly uninformative ("not asserted" for
31/// every requirement) and is suppressed.
32#[derive(Debug, Clone)]
33pub enum Verdict {
34    /// Every effective requirement passed.
35    Pass,
36    /// At least one requirement failed; one entry per failing
37    /// requirement.
38    Fail(Vec<FailureReason>),
39    /// No audit applies to this subject — the audit set passed to
40    /// [`evaluate`] was empty. Distinct from `Fail` because the
41    /// remediation is different: an unaudited subject needs an
42    /// audit, not a policy adjustment.
43    Unaudited,
44}
45
46/// Why a single requirement failed for the subject under evaluation.
47#[derive(Debug, Clone)]
48pub struct FailureReason {
49    /// The name of the requirement that failed.
50    pub requirement: String,
51    /// Whether the requirement was undecided or actively contradicted.
52    pub kind: FailureKind,
53}
54
55/// Kind of failure recorded by a [`FailureReason`].
56#[derive(Debug, Clone)]
57pub enum FailureKind {
58    /// No audit was able to assert this requirement to true (and
59    /// none explicitly failed it). The requirement remains
60    /// undecided.
61    NotAsserted,
62    /// At least one audit explicitly failed the requirement; the
63    /// per-audit claim values that drove the failure are included
64    /// for diagnostics.
65    Contradicted(Vec<AuditContradiction>),
66}
67
68/// One audit's contribution to a [`FailureKind::Contradicted`]
69/// outcome.
70#[derive(Debug, Clone)]
71pub struct AuditContradiction {
72    /// Name of the log this audit came from, as configured in
73    /// `openvet.toml`.
74    pub log: String,
75    /// Each claim referenced in the requirement expression and its
76    /// resolved tri-state for this audit (post-alias).
77    pub relevant_claims: Vec<(String, Tri)>,
78}
79
80/// Evaluate `policy` for `subject` against `audits`.
81///
82/// Each audit is paired with the name of the log it came from, as
83/// configured in `openvet.toml`. The log name drives alias
84/// resolution: a claim listed under `[alias]` is looked up by its
85/// alternate name for the named log and by the canonical name
86/// otherwise.
87pub fn evaluate(policy: &Policy, subject: &Subject, audits: &[(&str, &Audit)]) -> Verdict {
88    // Subject-level short-circuit: with no audits applying, every
89    // requirement would degenerate to NotAsserted with no per-audit
90    // detail to show. Surface this as its own outcome so callers can
91    // render "no matching audit" once instead of repeating it per
92    // requirement.
93    //
94    // The empty-effective-requirement case (an override stripped all
95    // requirements) is *not* unaudited — there's nothing to check, so
96    // it still passes trivially below.
97    let reqs = effective_requirements(policy, subject);
98    if audits.is_empty() && !reqs.is_empty() {
99        return Verdict::Unaudited;
100    }
101    let mut failures = Vec::new();
102    for name in reqs {
103        let Some(req) = policy.requirement(&name) else {
104            // Validated at parse time; should be unreachable.
105            continue;
106        };
107        if let Some(kind) = evaluate_requirement(policy, req, audits) {
108            failures.push(FailureReason {
109                requirement: name,
110                kind,
111            });
112        }
113    }
114    if failures.is_empty() {
115        Verdict::Pass
116    } else {
117        Verdict::Fail(failures)
118    }
119}
120
121/// Compute the effective requirement set for `subject`.
122///
123/// Starts from the policy's default-on requirements, then walks
124/// `[[override]]` blocks in declaration order, replacing or
125/// patching the working set when a matcher matches the subject.
126pub fn effective_requirements(policy: &Policy, subject: &Subject) -> Vec<String> {
127    let mut current: Vec<String> = policy
128        .requirements
129        .iter()
130        .filter(|r| r.default)
131        .map(|r| r.name.clone())
132        .collect();
133    for ov in &policy.overrides {
134        if !matches_subject(&ov.matcher, subject) {
135            continue;
136        }
137        match &ov.op {
138            OverrideOp::Replace(names) => current = names.clone(),
139            OverrideOp::Patch { add, remove } => {
140                current.retain(|n| !remove.contains(n));
141                for a in add {
142                    if !current.contains(a) {
143                        current.push(a.clone());
144                    }
145                }
146            }
147        }
148    }
149    current
150}
151
152fn evaluate_requirement(
153    policy: &Policy,
154    req: &Requirement,
155    audits: &[(&str, &Audit)],
156) -> Option<FailureKind> {
157    let mut some_true = false;
158    let mut contradictions: Vec<AuditContradiction> = Vec::new();
159    for (log, audit) in audits {
160        let lookup = |name: &str| resolve_claim(audit, log, name, &policy.aliases);
161        match expr::evaluate(&req.expr, &lookup) {
162            Tri::True => some_true = true,
163            Tri::False => {
164                contradictions.push(AuditContradiction {
165                    log: (*log).to_string(),
166                    relevant_claims: claim_snapshot(&req.expr, &lookup),
167                });
168            }
169            Tri::Unknown => {}
170        }
171    }
172    if !contradictions.is_empty() {
173        Some(FailureKind::Contradicted(contradictions))
174    } else if some_true {
175        None
176    } else {
177        Some(FailureKind::NotAsserted)
178    }
179}
180
181/// Build a claim-lookup closure for one audit, applying the
182/// policy's `[alias]` mappings.
183///
184/// Claims listed under `[alias]` are translated to the per-log
185/// alternative name; unaliased claims look up by canonical name.
186pub fn claim_lookup<'a>(
187    log: &'a str,
188    audit: &'a Audit,
189    aliases: &'a [Alias],
190) -> impl Fn(&str) -> Tri + 'a {
191    move |name: &str| resolve_claim(audit, log, name, aliases)
192}
193
194fn resolve_claim(audit: &Audit, log: &str, canonical: &str, aliases: &[Alias]) -> Tri {
195    // If `canonical` is aliased and a per-log mapping exists, use
196    // that name; otherwise fall through to the canonical name.
197    let actual = aliases
198        .iter()
199        .find(|a| a.canonical == canonical)
200        .and_then(|a| a.mappings.iter().find(|(l, _)| l == log))
201        .map(|(_, n)| n.as_str())
202        .unwrap_or(canonical);
203    match audit.claims.get(actual) {
204        Some(true) => Tri::True,
205        Some(false) => Tri::False,
206        None => Tri::Unknown,
207    }
208}
209
210/// Collect all claim names referenced by the expression (deduped,
211/// in first-encounter order) and their resolved tri-state under the
212/// given lookup. Used for failure diagnostics.
213fn claim_snapshot<F>(expr: &Expr, lookup: &F) -> Vec<(String, Tri)>
214where
215    F: Fn(&str) -> Tri,
216{
217    let mut names = Vec::new();
218    collect_claims(expr, &mut names);
219    names
220        .into_iter()
221        .map(|n| {
222            let v = lookup(&n);
223            (n, v)
224        })
225        .collect()
226}
227
228fn collect_claims(expr: &Expr, out: &mut Vec<String>) {
229    match expr {
230        Expr::Claim(name) => {
231            if !out.iter().any(|n| n == name) {
232                out.push(name.clone());
233            }
234        }
235        Expr::Not(inner) => collect_claims(inner, out),
236        Expr::And(children) | Expr::Or(children) => {
237            for c in children {
238                collect_claims(c, out);
239            }
240        }
241    }
242}
243
244// ──────────────────────────────────────────────────────────────────
245// Subject matching
246// ──────────────────────────────────────────────────────────────────
247
248fn matches_subject(m: &SubjectMatcher, s: &Subject) -> bool {
249    matches_str(&m.registry, &s.registry)
250        && matches_str(&m.package, &s.package)
251        && matches_str(&m.version, &s.version)
252        && matches_str(&m.variant, s.variant.as_deref().unwrap_or(""))
253        && matches_hash(&m.hash, &s.hash)
254}
255
256fn matches_str(matcher: &Option<String>, value: &str) -> bool {
257    match matcher.as_deref() {
258        None | Some("*") => true,
259        Some(s) => s == value,
260    }
261}
262
263fn matches_hash(matcher: &Option<String>, hash: &TaggedHash) -> bool {
264    match matcher.as_deref() {
265        None | Some("*") => true,
266        Some(s) => s == hash.to_string(),
267    }
268}
269
270impl fmt::Display for Tri {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        f.write_str(match self {
273            Tri::True => "true",
274            Tri::False => "false",
275            Tri::Unknown => "?",
276        })
277    }
278}
279
280impl fmt::Display for Verdict {
281    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282        match self {
283            Verdict::Pass => f.write_str("pass"),
284            Verdict::Unaudited => f.write_str("unaudited (no matching audit)"),
285            Verdict::Fail(reasons) => {
286                writeln!(f, "fail")?;
287                for r in reasons {
288                    writeln!(f, "  - {r}")?;
289                }
290                Ok(())
291            }
292        }
293    }
294}
295
296impl fmt::Display for FailureReason {
297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298        match &self.kind {
299            FailureKind::NotAsserted => {
300                write!(f, "no audit asserted requirement {:?}", self.requirement)
301            }
302            FailureKind::Contradicted(c) => {
303                writeln!(f, "requirement {:?} contradicted by:", self.requirement)?;
304                for ac in c {
305                    write!(f, "      log {:?}:", ac.log)?;
306                    for (name, tri) in &ac.relevant_claims {
307                        write!(f, " {name}={tri}")?;
308                    }
309                    writeln!(f)?;
310                }
311                Ok(())
312            }
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::config::parse_str;
321    use openvet_crypto::TaggedHash;
322    use openvet_proto::Subject;
323    use std::collections::BTreeMap;
324
325    fn subj(reg: &str, pkg: &str, ver: &str) -> Subject {
326        Subject {
327            registry: reg.into(),
328            package: pkg.into(),
329            version: ver.into(),
330            variant: None,
331            hash: TaggedHash::tagged("sha256", [0; 32]),
332        }
333    }
334
335    fn audit_with(claims: &[(&str, bool)]) -> Audit {
336        Audit::builder()
337            .subject(subj("cargo", "anything", "0.0.0"))
338            .claims(
339                claims
340                    .iter()
341                    .map(|(k, v)| ((*k).to_string(), *v))
342                    .collect::<BTreeMap<_, _>>(),
343            )
344            .build()
345    }
346
347    #[test]
348    fn passes_when_default_requirement_satisfied() {
349        let p = parse_str(
350            r#"
351            [requirement]
352            std-deploy = "safe-to-deploy"
353        "#,
354        )
355        .unwrap();
356        let a = audit_with(&[("safe-to-deploy", true)]);
357        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[("alice", &a)]);
358        assert!(matches!(v, Verdict::Pass));
359    }
360
361    #[test]
362    fn unaudited_when_audit_set_is_empty() {
363        // No audits at all → subject-level Unaudited, not a
364        // per-requirement NotAsserted.
365        let p = parse_str(
366            r#"
367            [requirement]
368            std-deploy = "safe-to-deploy"
369        "#,
370        )
371        .unwrap();
372        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[]);
373        assert!(matches!(v, Verdict::Unaudited));
374    }
375
376    #[test]
377    fn empty_requirement_set_passes_trivially_even_when_unaudited() {
378        // An override stripped every requirement. No requirements to
379        // satisfy → trivially Pass, regardless of audit presence.
380        let p = parse_str(
381            r#"
382            [requirement]
383            r1 = "safe-to-deploy"
384            [[override]]
385            package = "x"
386            requirements = []
387        "#,
388        )
389        .unwrap();
390        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[]);
391        assert!(matches!(v, Verdict::Pass));
392    }
393
394    #[test]
395    fn fails_not_asserted_when_no_audit_speaks() {
396        let p = parse_str(
397            r#"
398            [requirement]
399            std-deploy = "safe-to-deploy"
400        "#,
401        )
402        .unwrap();
403        let a = audit_with(&[]);
404        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[("alice", &a)]);
405        match v {
406            Verdict::Fail(rs) => {
407                assert_eq!(rs.len(), 1);
408                assert!(matches!(rs[0].kind, FailureKind::NotAsserted));
409            }
410            _ => panic!("expected Fail"),
411        }
412    }
413
414    #[test]
415    fn fails_contradicted_when_audit_says_false() {
416        let p = parse_str(
417            r#"
418            [requirement]
419            std-deploy = "safe-to-deploy"
420        "#,
421        )
422        .unwrap();
423        let asserted = audit_with(&[("safe-to-deploy", true)]);
424        let denied = audit_with(&[("safe-to-deploy", false)]);
425        let v = evaluate(
426            &p,
427            &subj("cargo", "x", "1.0"),
428            &[("alice", &asserted), ("bob", &denied)],
429        );
430        match v {
431            Verdict::Fail(rs) => {
432                assert!(matches!(rs[0].kind, FailureKind::Contradicted(_)));
433            }
434            _ => panic!("expected Fail (one audit asserts; another contradicts)"),
435        }
436    }
437
438    #[test]
439    fn at_least_one_true_passes_when_others_unknown() {
440        let p = parse_str(
441            r#"
442            [requirement]
443            std-deploy = "safe-to-deploy"
444        "#,
445        )
446        .unwrap();
447        let asserted = audit_with(&[("safe-to-deploy", true)]);
448        let silent = audit_with(&[]);
449        let v = evaluate(
450            &p,
451            &subj("cargo", "x", "1.0"),
452            &[("alice", &asserted), ("bob", &silent)],
453        );
454        assert!(matches!(v, Verdict::Pass));
455    }
456
457    #[test]
458    fn override_replace_swaps_requirement_set() {
459        let p = parse_str(
460            r#"
461            [requirement]
462            r1 = "safe-to-deploy"
463            r2 = { condition = "safe-to-run", default = false }
464            [[override]]
465            package = "x"
466            requirements = ["r2"]
467        "#,
468        )
469        .unwrap();
470        // Without the override, only r1 (default) applies. With the
471        // override, only r2 applies — and r2 is satisfied here.
472        let a = audit_with(&[("safe-to-run", true)]);
473        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[("alice", &a)]);
474        assert!(matches!(v, Verdict::Pass));
475    }
476
477    #[test]
478    fn override_patch_adds_and_removes() {
479        let p = parse_str(
480            r#"
481            [requirement]
482            r1 = "safe-to-deploy"
483            r2 = { condition = "safe-to-run", default = false }
484            [[override]]
485            package = "x"
486            requirements = { add = ["r2"], remove = ["r1"] }
487        "#,
488        )
489        .unwrap();
490        // After patch: r1 removed, r2 required. Audit asserts r2 only.
491        let a = audit_with(&[("safe-to-run", true)]);
492        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[("alice", &a)]);
493        assert!(matches!(v, Verdict::Pass));
494    }
495
496    #[test]
497    fn override_only_applies_to_matching_subject() {
498        let p = parse_str(
499            r#"
500            [requirement]
501            r1 = "safe-to-deploy"
502            r2 = { condition = "safe-to-run", default = false }
503            [[override]]
504            package = "x"
505            requirements = ["r2"]
506        "#,
507        )
508        .unwrap();
509        // Subject `y` doesn't match the override, so the default
510        // requirement r1 still applies, and the audit doesn't
511        // satisfy it.
512        let a = audit_with(&[("safe-to-run", true)]);
513        let v = evaluate(&p, &subj("cargo", "y", "1.0"), &[("alice", &a)]);
514        assert!(matches!(v, Verdict::Fail(_)));
515    }
516
517    #[test]
518    fn alias_translates_claim_name_per_log() {
519        let p = parse_str(
520            r#"
521            [requirement]
522            r = "safe-to-run"
523            [alias]
524            safe-to-run = ["mozilla:runtime-safe"]
525        "#,
526        )
527        .unwrap();
528        // mozilla audit uses `runtime-safe` (its native name).
529        let m = audit_with(&[("runtime-safe", true)]);
530        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[("mozilla", &m)]);
531        assert!(matches!(v, Verdict::Pass));
532    }
533
534    #[test]
535    fn alias_falls_back_to_canonical_for_unlisted_log() {
536        let p = parse_str(
537            r#"
538            [requirement]
539            r = "safe-to-run"
540            [alias]
541            safe-to-run = ["mozilla:runtime-safe"]
542        "#,
543        )
544        .unwrap();
545        // alice isn't in the alias mapping; the canonical name is
546        // looked up directly. alice's audit asserts it.
547        let a = audit_with(&[("safe-to-run", true)]);
548        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[("alice", &a)]);
549        assert!(matches!(v, Verdict::Pass));
550    }
551
552    #[test]
553    fn all_requirements_must_pass() {
554        let p = parse_str(
555            r#"
556            [requirement]
557            r1 = "safe-to-deploy"
558            r2 = "safe-to-run"
559        "#,
560        )
561        .unwrap();
562        // r1 satisfied; r2 silent → overall Fail (NotAsserted on r2).
563        let a = audit_with(&[("safe-to-deploy", true)]);
564        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[("alice", &a)]);
565        assert!(matches!(v, Verdict::Fail(_)));
566    }
567
568    #[test]
569    fn version_matcher_requires_exact() {
570        let p = parse_str(
571            r#"
572            [requirement]
573            r = "safe-to-deploy"
574            [[override]]
575            package = "x"
576            version = "1.0.0"
577            requirements = []
578        "#,
579        )
580        .unwrap();
581        // Override matches 1.0.0 → empty requirements → trivially pass.
582        let a = audit_with(&[]);
583        let v = evaluate(&p, &subj("cargo", "x", "1.0.0"), &[("alice", &a)]);
584        assert!(matches!(v, Verdict::Pass));
585        // Doesn't match 1.0.1 → default requirement still applies.
586        let v = evaluate(&p, &subj("cargo", "x", "1.0.1"), &[("alice", &a)]);
587        assert!(matches!(v, Verdict::Fail(_)));
588    }
589
590    #[test]
591    fn star_is_explicit_wildcard() {
592        let p = parse_str(
593            r#"
594            [requirement]
595            r = "safe-to-deploy"
596            [[override]]
597            registry = "*"
598            package = "x"
599            requirements = []
600        "#,
601        )
602        .unwrap();
603        let a = audit_with(&[]);
604        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[("alice", &a)]);
605        assert!(matches!(v, Verdict::Pass));
606    }
607
608    #[test]
609    fn display_prose_for_fail_includes_diagnostic() {
610        let p = parse_str(
611            r#"
612            [requirement]
613            r = "safe-to-deploy and safe-to-run"
614        "#,
615        )
616        .unwrap();
617        let a = audit_with(&[("safe-to-deploy", true), ("safe-to-run", false)]);
618        let v = evaluate(&p, &subj("cargo", "x", "1.0"), &[("alice", &a)]);
619        let s = format!("{v}");
620        assert!(s.contains("fail"));
621        assert!(s.contains("safe-to-run"));
622        assert!(s.contains("alice"));
623    }
624}