Skip to main content

atd_runtime/
capability.rs

1//! Connection-scoped capability allow-list.
2//!
3//! SP-12 demonstrates the §VI least-privilege shape without committing to a
4//! cryptographic token format. Capabilities are trusted free-form strings the
5//! server operator declares at start time via `--grant-capability`; the
6//! connection's `Hello` request asks for a subset, and the server replies with
7//! what it granted. A future SP can swap this allow-list for UCAN verification
8//! without changing `CapabilitySet`'s public surface.
9
10use std::collections::BTreeSet;
11
12/// Set of capabilities currently granted to a connection. Deterministically
13/// ordered (BTreeSet) so `granted()` returns a stable sequence — important for
14/// wire-level reproducibility and test assertions.
15#[derive(Debug, Clone, Default)]
16pub struct CapabilitySet {
17    granted: BTreeSet<String>,
18}
19
20impl CapabilitySet {
21    pub fn empty() -> Self {
22        Self::default()
23    }
24
25    pub fn contains(&self, cap: &str) -> bool {
26        self.granted.contains(cap)
27    }
28
29    /// Sorted, deterministic view of the granted set.
30    pub fn granted(&self) -> Vec<String> {
31        self.granted.iter().cloned().collect()
32    }
33
34    /// Intersect `requested` with the server's allow-list. Returns
35    /// `(granted_subset, denied_subset)`, each deterministically ordered.
36    /// Used during the `Hello` handshake to decide what to echo back.
37    pub fn intersect(&self, requested: &[String]) -> (Vec<String>, Vec<String>) {
38        let mut granted: Vec<String> = Vec::new();
39        let mut denied: Vec<String> = Vec::new();
40        let req_sorted: BTreeSet<&String> = requested.iter().collect();
41        for r in req_sorted {
42            if self.granted.contains(r) {
43                granted.push(r.clone());
44            } else {
45                denied.push(r.clone());
46            }
47        }
48        (granted, denied)
49    }
50
51    /// Union two capability sets. Used by SP-capability-v2 dispatch to
52    /// combine SP-12 string-allow-list results with UCAN-derived caps
53    /// (spec §4.2: `granted = granted_strings ∪ granted_ucan`). Returns
54    /// a new set; neither input is mutated.
55    pub fn union(&self, other: &Self) -> Self {
56        Self {
57            granted: self.granted.union(&other.granted).cloned().collect(),
58        }
59    }
60}
61
62impl FromIterator<String> for CapabilitySet {
63    fn from_iter<I: IntoIterator<Item = String>>(iter: I) -> Self {
64        Self {
65            granted: iter.into_iter().collect(),
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn empty_contains_nothing() {
76        let s = CapabilitySet::empty();
77        assert!(!s.contains("anything"));
78        assert!(s.granted().is_empty());
79    }
80
81    #[test]
82    fn from_iter_builds_set() {
83        let s = CapabilitySet::from_iter(["read".to_string(), "exec".to_string()]);
84        assert!(s.contains("read"));
85        assert!(s.contains("exec"));
86        assert!(!s.contains("admin"));
87    }
88
89    #[test]
90    fn granted_returns_sorted_deterministic_order() {
91        // Insert in reverse; output must be sorted.
92        let s =
93            CapabilitySet::from_iter(["zeta".to_string(), "alpha".to_string(), "mu".to_string()]);
94        assert_eq!(s.granted(), vec!["alpha", "mu", "zeta"]);
95    }
96
97    #[test]
98    fn intersect_with_empty_granted_denies_all() {
99        let s = CapabilitySet::empty();
100        let (granted, denied) = s.intersect(&["read".into(), "exec".into()]);
101        assert!(granted.is_empty());
102        assert_eq!(denied, vec!["exec", "read"]);
103    }
104
105    #[test]
106    fn intersect_partial() {
107        let s = CapabilitySet::from_iter(["read".to_string(), "exec".to_string()]);
108        let (granted, denied) = s.intersect(&["read".into(), "admin".into(), "exec".into()]);
109        assert_eq!(granted, vec!["exec", "read"]);
110        assert_eq!(denied, vec!["admin"]);
111    }
112
113    #[test]
114    fn intersect_full_grants_all_requested() {
115        let s = CapabilitySet::from_iter(["a".to_string(), "b".to_string(), "c".to_string()]);
116        let (granted, denied) = s.intersect(&["a".into(), "b".into()]);
117        assert_eq!(granted, vec!["a", "b"]);
118        assert!(denied.is_empty());
119    }
120
121    #[test]
122    fn intersect_dedupes_via_sorted_input() {
123        // BTreeSet in `intersect` deduplicates repeated requests.
124        let s = CapabilitySet::from_iter(["read".to_string()]);
125        let (granted, denied) = s.intersect(&["read".into(), "read".into(), "admin".into()]);
126        assert_eq!(granted, vec!["read"]);
127        assert_eq!(denied, vec!["admin"]);
128    }
129}