1use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19#[non_exhaustive]
20pub enum Verdict {
21 Allow,
23 #[serde(alias = "ask")]
26 Review,
27 Deny,
29 Defer,
32}
33
34impl Verdict {
35 fn strictness(self) -> u8 {
37 match self {
38 Self::Allow => 0,
39 Self::Defer => 1,
40 Self::Review => 2,
41 Self::Deny => 3,
42 }
43 }
44}
45
46impl PartialOrd for Verdict {
47 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
48 Some(self.cmp(other))
49 }
50}
51
52impl Ord for Verdict {
53 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
54 self.strictness().cmp(&other.strictness())
55 }
56}
57
58impl Verdict {
59 #[must_use]
62 pub fn as_str(self) -> &'static str {
63 match self {
64 Self::Allow => "allow",
65 Self::Review => "review",
66 Self::Deny => "deny",
67 Self::Defer => "defer",
68 }
69 }
70}
71
72pub const OLD_REVIEW: &str = "ask";
75
76#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
78#[error("unknown verdict `{0}`")]
79pub struct UnknownVerdict(pub String);
80
81impl FromStr for Verdict {
82 type Err = UnknownVerdict;
83
84 fn from_str(s: &str) -> Result<Self, Self::Err> {
85 if s == OLD_REVIEW {
86 return Ok(Self::Review);
87 }
88 [Self::Allow, Self::Defer, Self::Review, Self::Deny]
89 .into_iter()
90 .find(|v| v.as_str() == s)
91 .ok_or_else(|| UnknownVerdict(s.to_owned()))
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Decision {
103 pub verdict: Verdict,
105 pub rules: Vec<String>,
107 pub reasons: Vec<String>,
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub context: Vec<String>,
112}
113
114impl Decision {
115 #[must_use]
117 pub fn new(verdict: Verdict) -> Self {
118 Self {
119 verdict,
120 rules: Vec::new(),
121 reasons: Vec::new(),
122 context: Vec::new(),
123 }
124 }
125
126 #[must_use]
128 pub fn single(verdict: Verdict, rule: &str, reason: impl Into<String>) -> Self {
129 let mut decision = Self::new(verdict);
130 decision.push(rule, reason.into());
131 decision
132 }
133
134 pub(crate) fn push(&mut self, rule: &str, reason: String) {
135 if !self.rules.iter().any(|r| r == rule) {
136 self.rules.push(rule.to_owned());
137 }
138 if !self.reasons.contains(&reason) {
139 self.reasons.push(reason);
140 }
141 }
142
143 pub(crate) fn merge(&mut self, other: Decision) {
146 if other.verdict > self.verdict {
147 let demoted: Vec<String> = std::mem::take(&mut self.reasons);
148 self.rules.clear();
149 self.context.extend(demoted);
150 self.verdict = other.verdict;
151 } else if other.verdict < self.verdict {
152 self.context.extend(other.reasons);
153 self.context.extend(other.context);
154 return;
155 }
156 for (r, why) in other.rules.into_iter().zip(other.reasons) {
157 self.push(&r, why);
158 }
159 self.context.extend(other.context);
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::{Decision, UnknownVerdict, Verdict};
166
167 #[test]
168 fn verdict_names_round_trip() {
169 for v in [
170 Verdict::Allow,
171 Verdict::Defer,
172 Verdict::Review,
173 Verdict::Deny,
174 ] {
175 assert_eq!(v.as_str().parse::<Verdict>(), Ok(v));
176 }
177 assert_eq!(
178 "block".parse::<Verdict>(),
179 Err(UnknownVerdict("block".into()))
180 );
181 }
182
183 #[test]
186 fn the_old_name_ask_reads_as_review_and_is_never_written() {
187 assert_eq!("ask".parse::<Verdict>(), Ok(Verdict::Review));
188 assert_eq!(
189 serde_json::from_str::<Verdict>("\"ask\"").unwrap(),
190 Verdict::Review
191 );
192 assert_eq!(
193 serde_json::to_string(&Verdict::Review).unwrap(),
194 "\"review\""
195 );
196 assert_eq!(Verdict::Review.as_str(), "review");
197 let d: Decision =
198 serde_json::from_str(r#"{"verdict":"ask","rules":["installs"],"reasons":["r"]}"#)
199 .unwrap();
200 assert_eq!(d.verdict, Verdict::Review);
201 }
202
203 #[test]
204 fn single_carries_one_rule_and_reason() {
205 let d = Decision::single(Verdict::Deny, "kernel-error", "no lock");
206 assert_eq!(d.rules, ["kernel-error"]);
207 assert_eq!(d.reasons, ["no lock"]);
208 assert_eq!(d.context, Vec::<String>::new());
209 }
210
211 fn decided(verdict: Verdict, rule: &str) -> Decision {
212 let mut d = Decision::new(verdict);
213 d.push(rule, format!("because {rule}"));
214 d
215 }
216
217 #[test]
218 fn stricter_verdict_replaces_and_demotes_the_weaker_match() {
219 let mut d = decided(Verdict::Allow, "project-fs");
220 d.merge(decided(Verdict::Deny, "secrets-paths"));
221 assert_eq!(d.verdict, Verdict::Deny);
222 assert_eq!(d.rules, ["secrets-paths"]);
223 assert_eq!(d.reasons, ["because secrets-paths"]);
224 assert_eq!(d.context, ["because project-fs"]);
225 }
226
227 #[test]
228 fn weaker_verdict_only_adds_context() {
229 let mut d = decided(Verdict::Deny, "secrets-paths");
230 d.merge(decided(Verdict::Allow, "dev-shell"));
231 assert_eq!(d.verdict, Verdict::Deny);
232 assert_eq!(d.rules, ["secrets-paths"]);
233 assert_eq!(d.context, ["because dev-shell"]);
234 }
235
236 #[test]
237 fn equal_verdicts_union_rules_without_duplicates() {
238 let mut d = decided(Verdict::Review, "installs");
239 d.merge(decided(Verdict::Review, "installs"));
240 d.merge(decided(Verdict::Review, "push"));
241 assert_eq!(d.rules, ["installs", "push"]);
242 assert_eq!(d.reasons.len(), 2);
243 assert_eq!(d.context, Vec::<String>::new());
244 }
245
246 #[test]
247 fn verdict_order_is_allow_defer_review_deny() {
248 assert!(Verdict::Allow < Verdict::Defer);
249 assert!(Verdict::Defer < Verdict::Review && Verdict::Review < Verdict::Deny);
250 }
251}