use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Verdict {
Allow,
Ask,
Deny,
}
impl Verdict {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Ask => "ask",
Self::Deny => "deny",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown verdict `{0}`")]
pub struct UnknownVerdict(pub String);
impl FromStr for Verdict {
type Err = UnknownVerdict;
fn from_str(s: &str) -> Result<Self, Self::Err> {
[Self::Allow, Self::Ask, Self::Deny]
.into_iter()
.find(|v| v.as_str() == s)
.ok_or_else(|| UnknownVerdict(s.to_owned()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decision {
pub verdict: Verdict,
pub rules: Vec<String>,
pub reasons: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context: Vec<String>,
}
impl Decision {
#[must_use]
pub fn new(verdict: Verdict) -> Self {
Self {
verdict,
rules: Vec::new(),
reasons: Vec::new(),
context: Vec::new(),
}
}
#[must_use]
pub fn single(verdict: Verdict, rule: &str, reason: impl Into<String>) -> Self {
let mut decision = Self::new(verdict);
decision.push(rule, reason.into());
decision
}
pub(crate) fn push(&mut self, rule: &str, reason: String) {
if !self.rules.iter().any(|r| r == rule) {
self.rules.push(rule.to_owned());
}
if !self.reasons.contains(&reason) {
self.reasons.push(reason);
}
}
pub(crate) fn merge(&mut self, other: Decision) {
if other.verdict > self.verdict {
let demoted: Vec<String> = self.reasons.drain(..).collect();
self.rules.clear();
self.context.extend(demoted);
self.verdict = other.verdict;
} else if other.verdict < self.verdict {
self.context.extend(other.reasons);
self.context.extend(other.context);
return;
}
for (r, why) in other.rules.into_iter().zip(other.reasons) {
self.push(&r, why);
}
self.context.extend(other.context);
}
}
#[cfg(test)]
mod tests {
use super::{Decision, UnknownVerdict, Verdict};
#[test]
fn verdict_names_round_trip() {
for v in [Verdict::Allow, Verdict::Ask, Verdict::Deny] {
assert_eq!(v.as_str().parse::<Verdict>(), Ok(v));
}
assert_eq!(
"block".parse::<Verdict>(),
Err(UnknownVerdict("block".into()))
);
}
#[test]
fn single_carries_one_rule_and_reason() {
let d = Decision::single(Verdict::Deny, "kernel-error", "no lock");
assert_eq!(d.rules, ["kernel-error"]);
assert_eq!(d.reasons, ["no lock"]);
assert!(d.context.is_empty());
}
fn decided(verdict: Verdict, rule: &str) -> Decision {
let mut d = Decision::new(verdict);
d.push(rule, format!("because {rule}"));
d
}
#[test]
fn stricter_verdict_replaces_and_demotes_the_weaker_match() {
let mut d = decided(Verdict::Allow, "project-fs");
d.merge(decided(Verdict::Deny, "secrets-paths"));
assert_eq!(d.verdict, Verdict::Deny);
assert_eq!(d.rules, ["secrets-paths"]);
assert_eq!(d.reasons, ["because secrets-paths"]);
assert_eq!(d.context, ["because project-fs"]);
}
#[test]
fn weaker_verdict_only_adds_context() {
let mut d = decided(Verdict::Deny, "secrets-paths");
d.merge(decided(Verdict::Allow, "dev-shell"));
assert_eq!(d.verdict, Verdict::Deny);
assert_eq!(d.rules, ["secrets-paths"]);
assert_eq!(d.context, ["because dev-shell"]);
}
#[test]
fn equal_verdicts_union_rules_without_duplicates() {
let mut d = decided(Verdict::Ask, "installs");
d.merge(decided(Verdict::Ask, "installs"));
d.merge(decided(Verdict::Ask, "push"));
assert_eq!(d.rules, ["installs", "push"]);
assert_eq!(d.reasons.len(), 2);
assert!(d.context.is_empty());
}
#[test]
fn verdict_order_is_allow_ask_deny() {
assert!(Verdict::Allow < Verdict::Ask && Verdict::Ask < Verdict::Deny);
}
}