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" => {
46                (SafetyLevel::SafeWrite, None)
47            }
48            // Legacy names — kept working so existing setups/muscle-memory don't break; the CLI
49            // prints a one-line notice pointing at the current name.
50            "inert" => (SafetyLevel::Inert, Some("paranoid")),
51            "safe-read" => (SafetyLevel::SafeRead, Some("reader")),
52            "safe-write" => (SafetyLevel::SafeWrite, Some("developer")),
53            _ => return None,
54        })
55    }
56}
57
58impl fmt::Display for SafetyLevel {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            SafetyLevel::Inert => write!(f, "inert"),
62            SafetyLevel::SafeRead => write!(f, "safe-read"),
63            SafetyLevel::SafeWrite => write!(f, "safe-write"),
64        }
65    }
66}
67
68impl fmt::Display for Verdict {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Verdict::Denied => write!(f, "denied"),
72            Verdict::Allowed(level) => write!(f, "allowed ({level})"),
73        }
74    }
75}
76
77impl clap::ValueEnum for SafetyLevel {
78    fn value_variants<'a>() -> &'a [Self] {
79        &[SafetyLevel::Inert, SafetyLevel::SafeRead, SafetyLevel::SafeWrite]
80    }
81
82    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
83        match self {
84            SafetyLevel::Inert => Some(clap::builder::PossibleValue::new("inert")),
85            SafetyLevel::SafeRead => Some(clap::builder::PossibleValue::new("safe-read")),
86            SafetyLevel::SafeWrite => Some(clap::builder::PossibleValue::new("safe-write")),
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn level_ordering() {
97        assert!(SafetyLevel::Inert < SafetyLevel::SafeRead);
98        assert!(SafetyLevel::SafeRead < SafetyLevel::SafeWrite);
99    }
100
101    #[test]
102    fn threshold_names_map_new_and_legacy() {
103        // current names — no legacy notice
104        assert_eq!(SafetyLevel::resolve_threshold("paranoid"), Some((SafetyLevel::Inert, None)));
105        assert_eq!(SafetyLevel::resolve_threshold("reader"), Some((SafetyLevel::SafeRead, None)));
106        assert_eq!(SafetyLevel::resolve_threshold("editor"), Some((SafetyLevel::SafeWrite, None)));
107        assert_eq!(SafetyLevel::resolve_threshold("developer"), Some((SafetyLevel::SafeWrite, None)));
108        assert_eq!(SafetyLevel::resolve_threshold("yolo"), Some((SafetyLevel::SafeWrite, None)));
109
110        // legacy names — same ceiling as before, carrying the current name for the notice
111        assert_eq!(SafetyLevel::resolve_threshold("inert"), Some((SafetyLevel::Inert, Some("paranoid"))));
112        assert_eq!(SafetyLevel::resolve_threshold("safe-read"), Some((SafetyLevel::SafeRead, Some("reader"))));
113        assert_eq!(SafetyLevel::resolve_threshold("safe-write"), Some((SafetyLevel::SafeWrite, Some("developer"))));
114
115        // the legacy inputs preserve their historical ceiling exactly (no setup breaks)
116        assert_eq!(SafetyLevel::resolve_threshold("inert").unwrap().0, SafetyLevel::Inert);
117        assert_eq!(SafetyLevel::resolve_threshold("safe-write").unwrap().0, SafetyLevel::SafeWrite);
118
119        assert_eq!(SafetyLevel::resolve_threshold("nonsense"), None);
120    }
121
122    #[test]
123    fn combine_both_allowed() {
124        let a = Verdict::Allowed(SafetyLevel::Inert);
125        let b = Verdict::Allowed(SafetyLevel::SafeRead);
126        assert_eq!(a.combine(b), Verdict::Allowed(SafetyLevel::SafeRead));
127    }
128
129    #[test]
130    fn combine_one_denied() {
131        let a = Verdict::Allowed(SafetyLevel::Inert);
132        assert_eq!(a.combine(Verdict::Denied), Verdict::Denied);
133        assert_eq!(Verdict::Denied.combine(a), Verdict::Denied);
134    }
135
136    #[test]
137    fn combine_identity() {
138        let a = Verdict::Allowed(SafetyLevel::SafeWrite);
139        let identity = Verdict::Allowed(SafetyLevel::Inert);
140        assert_eq!(identity.combine(a), a);
141    }
142}