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 #[serde(skip)]
117 pub notice: Option<Box<Notice>>,
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq)]
124pub struct Notice {
125 pub title: String,
127 pub why: Option<String>,
129 pub call: Option<String>,
132 pub detail: Option<String>,
135 pub unasked: Option<String>,
138}
139
140impl Decision {
141 #[must_use]
143 pub fn new(verdict: Verdict) -> Self {
144 Self {
145 verdict,
146 rules: Vec::new(),
147 reasons: Vec::new(),
148 context: Vec::new(),
149 notice: None,
150 }
151 }
152
153 #[must_use]
155 pub fn single(verdict: Verdict, rule: &str, reason: impl Into<String>) -> Self {
156 let mut decision = Self::new(verdict);
157 decision.push(rule, reason.into());
158 decision
159 }
160
161 pub(crate) fn push(&mut self, rule: &str, reason: String) {
162 if !self.rules.iter().any(|r| r == rule) {
163 self.rules.push(rule.to_owned());
164 }
165 if !self.reasons.contains(&reason) {
166 self.reasons.push(reason);
167 }
168 }
169
170 pub(crate) fn merge(&mut self, other: Decision) {
173 if other.verdict > self.verdict {
174 let demoted: Vec<String> = std::mem::take(&mut self.reasons);
175 self.rules.clear();
176 self.context.extend(demoted);
177 self.verdict = other.verdict;
178 } else if other.verdict < self.verdict {
179 self.context.extend(other.reasons);
180 self.context.extend(other.context);
181 return;
182 }
183 for (r, why) in other.rules.into_iter().zip(other.reasons) {
184 self.push(&r, why);
185 }
186 self.context.extend(other.context);
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::{Decision, UnknownVerdict, Verdict};
193
194 #[test]
195 fn verdict_names_round_trip() {
196 for v in [
197 Verdict::Allow,
198 Verdict::Defer,
199 Verdict::Review,
200 Verdict::Deny,
201 ] {
202 assert_eq!(v.as_str().parse::<Verdict>(), Ok(v));
203 }
204 assert_eq!(
205 "block".parse::<Verdict>(),
206 Err(UnknownVerdict("block".into()))
207 );
208 }
209
210 #[test]
213 fn the_old_name_ask_reads_as_review_and_is_never_written() {
214 assert_eq!("ask".parse::<Verdict>(), Ok(Verdict::Review));
215 assert_eq!(
216 serde_json::from_str::<Verdict>("\"ask\"").unwrap(),
217 Verdict::Review
218 );
219 assert_eq!(
220 serde_json::to_string(&Verdict::Review).unwrap(),
221 "\"review\""
222 );
223 assert_eq!(Verdict::Review.as_str(), "review");
224 let d: Decision =
225 serde_json::from_str(r#"{"verdict":"ask","rules":["installs"],"reasons":["r"]}"#)
226 .unwrap();
227 assert_eq!(d.verdict, Verdict::Review);
228 }
229
230 #[test]
232 fn a_notice_is_never_serialised() {
233 let mut d = Decision::single(Verdict::Deny, "secrets-paths", "secret material");
234 let plain = serde_json::to_string(&d).unwrap();
235 d.notice = Some(Box::new(super::Notice {
236 title: "reading your SSH private key".into(),
237 ..super::Notice::default()
238 }));
239 assert_eq!(serde_json::to_string(&d).unwrap(), plain);
240 let read: Decision = serde_json::from_str(&plain).unwrap();
241 assert_eq!(read.notice, None);
242 }
243
244 #[test]
245 fn single_carries_one_rule_and_reason() {
246 let d = Decision::single(Verdict::Deny, "kernel-error", "no lock");
247 assert_eq!(d.rules, ["kernel-error"]);
248 assert_eq!(d.reasons, ["no lock"]);
249 assert_eq!(d.context, Vec::<String>::new());
250 }
251
252 fn decided(verdict: Verdict, rule: &str) -> Decision {
253 let mut d = Decision::new(verdict);
254 d.push(rule, format!("because {rule}"));
255 d
256 }
257
258 #[test]
259 fn stricter_verdict_replaces_and_demotes_the_weaker_match() {
260 let mut d = decided(Verdict::Allow, "project-fs");
261 d.merge(decided(Verdict::Deny, "secrets-paths"));
262 assert_eq!(d.verdict, Verdict::Deny);
263 assert_eq!(d.rules, ["secrets-paths"]);
264 assert_eq!(d.reasons, ["because secrets-paths"]);
265 assert_eq!(d.context, ["because project-fs"]);
266 }
267
268 #[test]
269 fn weaker_verdict_only_adds_context() {
270 let mut d = decided(Verdict::Deny, "secrets-paths");
271 d.merge(decided(Verdict::Allow, "dev-shell"));
272 assert_eq!(d.verdict, Verdict::Deny);
273 assert_eq!(d.rules, ["secrets-paths"]);
274 assert_eq!(d.context, ["because dev-shell"]);
275 }
276
277 #[test]
278 fn equal_verdicts_union_rules_without_duplicates() {
279 let mut d = decided(Verdict::Review, "installs");
280 d.merge(decided(Verdict::Review, "installs"));
281 d.merge(decided(Verdict::Review, "push"));
282 assert_eq!(d.rules, ["installs", "push"]);
283 assert_eq!(d.reasons.len(), 2);
284 assert_eq!(d.context, Vec::<String>::new());
285 }
286
287 #[test]
288 fn verdict_order_is_allow_defer_review_deny() {
289 assert!(Verdict::Allow < Verdict::Defer);
290 assert!(Verdict::Defer < Verdict::Review && Verdict::Review < Verdict::Deny);
291 }
292}