1use std::str::FromStr;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18#[non_exhaustive]
19pub enum Verdict {
20 Allow,
22 Ask,
24 Deny,
26 Defer,
29}
30
31impl Verdict {
32 fn strictness(self) -> u8 {
34 match self {
35 Self::Allow => 0,
36 Self::Defer => 1,
37 Self::Ask => 2,
38 Self::Deny => 3,
39 }
40 }
41}
42
43impl PartialOrd for Verdict {
44 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
45 Some(self.cmp(other))
46 }
47}
48
49impl Ord for Verdict {
50 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
51 self.strictness().cmp(&other.strictness())
52 }
53}
54
55impl Verdict {
56 #[must_use]
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Self::Allow => "allow",
61 Self::Ask => "ask",
62 Self::Deny => "deny",
63 Self::Defer => "defer",
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
70#[error("unknown verdict `{0}`")]
71pub struct UnknownVerdict(pub String);
72
73impl FromStr for Verdict {
74 type Err = UnknownVerdict;
75
76 fn from_str(s: &str) -> Result<Self, Self::Err> {
77 [Self::Allow, Self::Defer, Self::Ask, Self::Deny]
78 .into_iter()
79 .find(|v| v.as_str() == s)
80 .ok_or_else(|| UnknownVerdict(s.to_owned()))
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct Decision {
92 pub verdict: Verdict,
94 pub rules: Vec<String>,
96 pub reasons: Vec<String>,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub context: Vec<String>,
101}
102
103impl Decision {
104 #[must_use]
106 pub fn new(verdict: Verdict) -> Self {
107 Self {
108 verdict,
109 rules: Vec::new(),
110 reasons: Vec::new(),
111 context: Vec::new(),
112 }
113 }
114
115 #[must_use]
117 pub fn single(verdict: Verdict, rule: &str, reason: impl Into<String>) -> Self {
118 let mut decision = Self::new(verdict);
119 decision.push(rule, reason.into());
120 decision
121 }
122
123 pub(crate) fn push(&mut self, rule: &str, reason: String) {
124 if !self.rules.iter().any(|r| r == rule) {
125 self.rules.push(rule.to_owned());
126 }
127 if !self.reasons.contains(&reason) {
128 self.reasons.push(reason);
129 }
130 }
131
132 pub(crate) fn merge(&mut self, other: Decision) {
135 if other.verdict > self.verdict {
136 let demoted: Vec<String> = self.reasons.drain(..).collect();
137 self.rules.clear();
138 self.context.extend(demoted);
139 self.verdict = other.verdict;
140 } else if other.verdict < self.verdict {
141 self.context.extend(other.reasons);
142 self.context.extend(other.context);
143 return;
144 }
145 for (r, why) in other.rules.into_iter().zip(other.reasons) {
146 self.push(&r, why);
147 }
148 self.context.extend(other.context);
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::{Decision, UnknownVerdict, Verdict};
155
156 #[test]
157 fn verdict_names_round_trip() {
158 for v in [Verdict::Allow, Verdict::Defer, Verdict::Ask, Verdict::Deny] {
159 assert_eq!(v.as_str().parse::<Verdict>(), Ok(v));
160 }
161 assert_eq!(
162 "block".parse::<Verdict>(),
163 Err(UnknownVerdict("block".into()))
164 );
165 }
166
167 #[test]
168 fn single_carries_one_rule_and_reason() {
169 let d = Decision::single(Verdict::Deny, "kernel-error", "no lock");
170 assert_eq!(d.rules, ["kernel-error"]);
171 assert_eq!(d.reasons, ["no lock"]);
172 assert!(d.context.is_empty());
173 }
174
175 fn decided(verdict: Verdict, rule: &str) -> Decision {
176 let mut d = Decision::new(verdict);
177 d.push(rule, format!("because {rule}"));
178 d
179 }
180
181 #[test]
182 fn stricter_verdict_replaces_and_demotes_the_weaker_match() {
183 let mut d = decided(Verdict::Allow, "project-fs");
184 d.merge(decided(Verdict::Deny, "secrets-paths"));
185 assert_eq!(d.verdict, Verdict::Deny);
186 assert_eq!(d.rules, ["secrets-paths"]);
187 assert_eq!(d.reasons, ["because secrets-paths"]);
188 assert_eq!(d.context, ["because project-fs"]);
189 }
190
191 #[test]
192 fn weaker_verdict_only_adds_context() {
193 let mut d = decided(Verdict::Deny, "secrets-paths");
194 d.merge(decided(Verdict::Allow, "dev-shell"));
195 assert_eq!(d.verdict, Verdict::Deny);
196 assert_eq!(d.rules, ["secrets-paths"]);
197 assert_eq!(d.context, ["because dev-shell"]);
198 }
199
200 #[test]
201 fn equal_verdicts_union_rules_without_duplicates() {
202 let mut d = decided(Verdict::Ask, "installs");
203 d.merge(decided(Verdict::Ask, "installs"));
204 d.merge(decided(Verdict::Ask, "push"));
205 assert_eq!(d.rules, ["installs", "push"]);
206 assert_eq!(d.reasons.len(), 2);
207 assert!(d.context.is_empty());
208 }
209
210 #[test]
211 fn verdict_order_is_allow_defer_ask_deny() {
212 assert!(Verdict::Allow < Verdict::Defer);
213 assert!(Verdict::Defer < Verdict::Ask && Verdict::Ask < Verdict::Deny);
214 }
215}