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