Skip to main content

safe_chains/
verdict.rs

1use std::fmt;
2
3#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
4#[repr(u8)]
5pub enum SafetyLevel {
6    Inert = 0,
7    SafeRead = 1,
8    SafeWrite = 2,
9}
10
11#[derive(Copy, Clone, Debug, PartialEq, Eq)]
12pub enum Verdict {
13    Denied,
14    Allowed(SafetyLevel),
15}
16
17impl Verdict {
18    pub fn combine(self, other: Verdict) -> Verdict {
19        match (self, other) {
20            (Verdict::Denied, _) | (_, Verdict::Denied) => Verdict::Denied,
21            (Verdict::Allowed(a), Verdict::Allowed(b)) => Verdict::Allowed(a.max(b)),
22        }
23    }
24
25    pub fn is_allowed(self) -> bool {
26        matches!(self, Verdict::Allowed(_))
27    }
28}
29
30impl SafetyLevel {
31    /// Resolve a `--level` threshold name to its ceiling. Accepts the current level names
32    /// (`paranoid`/`reader`/`editor`/`developer`/`local-admin`/`network-admin`/`yolo`) and the
33    /// three legacy names (`inert`/`safe-read`/`safe-write`). Returns the ceiling plus, for a
34    /// legacy name, the current name it maps to (so the CLI can print a migration notice).
35    /// `None` for an unknown name.
36    ///
37    /// The engine projects to this coarse 3-value ceiling today, so `editor`/`developer` and the
38    /// `local-admin`/`network-admin`/`yolo` levels all share the `SafeWrite` ceiling — they
39    /// become functionally distinct thresholds once the engine exposes per-level classification
40    /// (the `level.admits(profile)` check) rather than the 3-value projection.
41    pub fn resolve_threshold(name: &str) -> Option<(SafetyLevel, Option<&'static str>)> {
42        Some(match name {
43            "paranoid" => (SafetyLevel::Inert, None),
44            "reader" => (SafetyLevel::SafeRead, None),
45            "editor" | "developer" | "local-admin" | "network-admin" | "yolo" => (SafetyLevel::SafeWrite, None),
46            // Legacy names — kept working so existing setups/muscle-memory don't break; the CLI
47            // prints a one-line notice pointing at the current name.
48            "inert" => (SafetyLevel::Inert, Some("paranoid")),
49            "safe-read" => (SafetyLevel::SafeRead, Some("reader")),
50            "safe-write" => (SafetyLevel::SafeWrite, Some("developer")),
51            _ => return None,
52        })
53    }
54}
55
56impl fmt::Display for SafetyLevel {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            SafetyLevel::Inert => write!(f, "inert"),
60            SafetyLevel::SafeRead => write!(f, "safe-read"),
61            SafetyLevel::SafeWrite => write!(f, "safe-write"),
62        }
63    }
64}
65
66impl fmt::Display for Verdict {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Verdict::Denied => write!(f, "denied"),
70            Verdict::Allowed(level) => write!(f, "allowed ({level})"),
71        }
72    }
73}
74
75impl clap::ValueEnum for SafetyLevel {
76    fn value_variants<'a>() -> &'a [Self] {
77        &[SafetyLevel::Inert, SafetyLevel::SafeRead, SafetyLevel::SafeWrite]
78    }
79
80    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
81        match self {
82            SafetyLevel::Inert => Some(clap::builder::PossibleValue::new("inert")),
83            SafetyLevel::SafeRead => Some(clap::builder::PossibleValue::new("safe-read")),
84            SafetyLevel::SafeWrite => Some(clap::builder::PossibleValue::new("safe-write")),
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn level_ordering() {
95        assert!(SafetyLevel::Inert < SafetyLevel::SafeRead);
96        assert!(SafetyLevel::SafeRead < SafetyLevel::SafeWrite);
97    }
98
99    #[test]
100    fn threshold_names_map_new_and_legacy() {
101        // current names — no legacy notice
102        assert_eq!(SafetyLevel::resolve_threshold("paranoid"), Some((SafetyLevel::Inert, None)));
103        assert_eq!(SafetyLevel::resolve_threshold("reader"), Some((SafetyLevel::SafeRead, None)));
104        assert_eq!(SafetyLevel::resolve_threshold("editor"), Some((SafetyLevel::SafeWrite, None)));
105        assert_eq!(SafetyLevel::resolve_threshold("developer"), Some((SafetyLevel::SafeWrite, None)));
106        assert_eq!(SafetyLevel::resolve_threshold("yolo"), Some((SafetyLevel::SafeWrite, None)));
107
108        // legacy names — same ceiling as before, carrying the current name for the notice
109        assert_eq!(SafetyLevel::resolve_threshold("inert"), Some((SafetyLevel::Inert, Some("paranoid"))));
110        assert_eq!(SafetyLevel::resolve_threshold("safe-read"), Some((SafetyLevel::SafeRead, Some("reader"))));
111        assert_eq!(SafetyLevel::resolve_threshold("safe-write"), Some((SafetyLevel::SafeWrite, Some("developer"))));
112
113        // the legacy inputs preserve their historical ceiling exactly (no setup breaks)
114        assert_eq!(SafetyLevel::resolve_threshold("inert").unwrap().0, SafetyLevel::Inert);
115        assert_eq!(SafetyLevel::resolve_threshold("safe-write").unwrap().0, SafetyLevel::SafeWrite);
116
117        assert_eq!(SafetyLevel::resolve_threshold("nonsense"), None);
118    }
119
120    #[test]
121    fn combine_both_allowed() {
122        let a = Verdict::Allowed(SafetyLevel::Inert);
123        let b = Verdict::Allowed(SafetyLevel::SafeRead);
124        assert_eq!(a.combine(b), Verdict::Allowed(SafetyLevel::SafeRead));
125    }
126
127    #[test]
128    fn combine_one_denied() {
129        let a = Verdict::Allowed(SafetyLevel::Inert);
130        assert_eq!(a.combine(Verdict::Denied), Verdict::Denied);
131        assert_eq!(Verdict::Denied.combine(a), Verdict::Denied);
132    }
133
134    #[test]
135    fn combine_identity() {
136        let a = Verdict::Allowed(SafetyLevel::SafeWrite);
137        let identity = Verdict::Allowed(SafetyLevel::Inert);
138        assert_eq!(identity.combine(a), a);
139    }
140}