1use 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#[derive(Debug, Clone)]
33pub enum Verdict {
34 Pass,
36 Fail(Vec<FailureReason>),
39 Unaudited,
44}
45
46#[derive(Debug, Clone)]
48pub struct FailureReason {
49 pub requirement: String,
51 pub kind: FailureKind,
53}
54
55#[derive(Debug, Clone)]
57pub enum FailureKind {
58 NotAsserted,
62 Contradicted(Vec<AuditContradiction>),
66}
67
68#[derive(Debug, Clone)]
71pub struct AuditContradiction {
72 pub log: String,
75 pub relevant_claims: Vec<(String, Tri)>,
78}
79
80pub fn evaluate(policy: &Policy, subject: &Subject, audits: &[(&str, &Audit)]) -> Verdict {
88 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 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
121pub 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
181pub 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 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
210fn 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
244fn 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 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 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 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 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 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 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 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 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 let a = audit_with(&[]);
583 let v = evaluate(&p, &subj("cargo", "x", "1.0.0"), &[("alice", &a)]);
584 assert!(matches!(v, Verdict::Pass));
585 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}