1use std::str::FromStr;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum Verdict {
12 Allow,
14 Ask,
16 Deny,
18}
19
20impl Verdict {
21 #[must_use]
23 pub fn as_str(self) -> &'static str {
24 match self {
25 Self::Allow => "allow",
26 Self::Ask => "ask",
27 Self::Deny => "deny",
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
34#[error("unknown verdict `{0}`")]
35pub struct UnknownVerdict(pub String);
36
37impl FromStr for Verdict {
38 type Err = UnknownVerdict;
39
40 fn from_str(s: &str) -> Result<Self, Self::Err> {
41 [Self::Allow, Self::Ask, Self::Deny]
42 .into_iter()
43 .find(|v| v.as_str() == s)
44 .ok_or_else(|| UnknownVerdict(s.to_owned()))
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct Decision {
56 pub verdict: Verdict,
58 pub rules: Vec<String>,
60 pub reasons: Vec<String>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub context: Vec<String>,
65}
66
67impl Decision {
68 #[must_use]
70 pub fn new(verdict: Verdict) -> Self {
71 Self {
72 verdict,
73 rules: Vec::new(),
74 reasons: Vec::new(),
75 context: Vec::new(),
76 }
77 }
78
79 #[must_use]
81 pub fn single(verdict: Verdict, rule: &str, reason: impl Into<String>) -> Self {
82 let mut decision = Self::new(verdict);
83 decision.push(rule, reason.into());
84 decision
85 }
86
87 pub(crate) fn push(&mut self, rule: &str, reason: String) {
88 if !self.rules.iter().any(|r| r == rule) {
89 self.rules.push(rule.to_owned());
90 }
91 if !self.reasons.contains(&reason) {
92 self.reasons.push(reason);
93 }
94 }
95
96 pub(crate) fn merge(&mut self, other: Decision) {
99 if other.verdict > self.verdict {
100 let demoted: Vec<String> = self.reasons.drain(..).collect();
101 self.rules.clear();
102 self.context.extend(demoted);
103 self.verdict = other.verdict;
104 } else if other.verdict < self.verdict {
105 self.context.extend(other.reasons);
106 self.context.extend(other.context);
107 return;
108 }
109 for (r, why) in other.rules.into_iter().zip(other.reasons) {
110 self.push(&r, why);
111 }
112 self.context.extend(other.context);
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::{Decision, UnknownVerdict, Verdict};
119
120 #[test]
121 fn verdict_names_round_trip() {
122 for v in [Verdict::Allow, Verdict::Ask, Verdict::Deny] {
123 assert_eq!(v.as_str().parse::<Verdict>(), Ok(v));
124 }
125 assert_eq!(
126 "block".parse::<Verdict>(),
127 Err(UnknownVerdict("block".into()))
128 );
129 }
130
131 #[test]
132 fn single_carries_one_rule_and_reason() {
133 let d = Decision::single(Verdict::Deny, "kernel-error", "no lock");
134 assert_eq!(d.rules, ["kernel-error"]);
135 assert_eq!(d.reasons, ["no lock"]);
136 assert!(d.context.is_empty());
137 }
138
139 fn decided(verdict: Verdict, rule: &str) -> Decision {
140 let mut d = Decision::new(verdict);
141 d.push(rule, format!("because {rule}"));
142 d
143 }
144
145 #[test]
146 fn stricter_verdict_replaces_and_demotes_the_weaker_match() {
147 let mut d = decided(Verdict::Allow, "project-fs");
148 d.merge(decided(Verdict::Deny, "secrets-paths"));
149 assert_eq!(d.verdict, Verdict::Deny);
150 assert_eq!(d.rules, ["secrets-paths"]);
151 assert_eq!(d.reasons, ["because secrets-paths"]);
152 assert_eq!(d.context, ["because project-fs"]);
153 }
154
155 #[test]
156 fn weaker_verdict_only_adds_context() {
157 let mut d = decided(Verdict::Deny, "secrets-paths");
158 d.merge(decided(Verdict::Allow, "dev-shell"));
159 assert_eq!(d.verdict, Verdict::Deny);
160 assert_eq!(d.rules, ["secrets-paths"]);
161 assert_eq!(d.context, ["because dev-shell"]);
162 }
163
164 #[test]
165 fn equal_verdicts_union_rules_without_duplicates() {
166 let mut d = decided(Verdict::Ask, "installs");
167 d.merge(decided(Verdict::Ask, "installs"));
168 d.merge(decided(Verdict::Ask, "push"));
169 assert_eq!(d.rules, ["installs", "push"]);
170 assert_eq!(d.reasons.len(), 2);
171 assert!(d.context.is_empty());
172 }
173
174 #[test]
175 fn verdict_order_is_allow_ask_deny() {
176 assert!(Verdict::Allow < Verdict::Ask && Verdict::Ask < Verdict::Deny);
177 }
178}