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