Skip to main content

rustant_core/nodes/
consent.rs

1//! Consent store — per-capability consent management per node.
2//!
3//! Supports permanent, time-limited, and one-time consent entries.
4
5use super::types::{Capability, NodeId};
6use chrono::{DateTime, Duration, Utc};
7use std::collections::HashMap;
8
9/// A single consent entry for a capability.
10#[derive(Debug, Clone)]
11pub struct ConsentEntry {
12    pub capability: Capability,
13    pub granted_at: DateTime<Utc>,
14    pub expires_at: Option<DateTime<Utc>>,
15    pub one_time: bool,
16    pub used: bool,
17}
18
19impl ConsentEntry {
20    /// Whether this entry is currently valid (not expired and not consumed).
21    pub fn is_valid(&self) -> bool {
22        if self.one_time && self.used {
23            return false;
24        }
25        if let Some(expires) = self.expires_at {
26            Utc::now() < expires
27        } else {
28            true
29        }
30    }
31}
32
33/// Stores granted/revoked consent per-node per-capability.
34#[derive(Debug, Clone, Default)]
35pub struct ConsentStore {
36    entries: HashMap<NodeId, Vec<ConsentEntry>>,
37}
38
39impl ConsentStore {
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Grant permanent consent for a capability on a node.
45    pub fn grant(&mut self, node_id: &NodeId, capability: Capability) {
46        let entry = ConsentEntry {
47            capability,
48            granted_at: Utc::now(),
49            expires_at: None,
50            one_time: false,
51            used: false,
52        };
53        self.entries.entry(node_id.clone()).or_default().push(entry);
54    }
55
56    /// Grant consent with a time-based expiry.
57    pub fn grant_with_expiry(
58        &mut self,
59        node_id: &NodeId,
60        capability: Capability,
61        duration: Duration,
62    ) -> ConsentEntry {
63        let now = Utc::now();
64        let entry = ConsentEntry {
65            capability,
66            granted_at: now,
67            expires_at: Some(now + duration),
68            one_time: false,
69            used: false,
70        };
71        self.entries
72            .entry(node_id.clone())
73            .or_default()
74            .push(entry.clone());
75        entry
76    }
77
78    /// Grant a one-time consent that can only be used once.
79    pub fn grant_one_time(&mut self, node_id: &NodeId, capability: Capability) -> ConsentEntry {
80        let entry = ConsentEntry {
81            capability,
82            granted_at: Utc::now(),
83            expires_at: None,
84            one_time: true,
85            used: false,
86        };
87        self.entries
88            .entry(node_id.clone())
89            .or_default()
90            .push(entry.clone());
91        entry
92    }
93
94    /// Revoke consent for a capability on a node (removes all matching entries).
95    pub fn revoke(&mut self, node_id: &NodeId, capability: &Capability) {
96        if let Some(entries) = self.entries.get_mut(node_id) {
97            entries.retain(|e| &e.capability != capability);
98            if entries.is_empty() {
99                self.entries.remove(node_id);
100            }
101        }
102    }
103
104    /// Check whether a capability is consented for a node.
105    /// Returns true if any valid entry exists for this capability.
106    pub fn is_granted(&self, node_id: &NodeId, capability: &Capability) -> bool {
107        self.entries.get(node_id).is_some_and(|entries| {
108            entries
109                .iter()
110                .any(|e| &e.capability == capability && e.is_valid())
111        })
112    }
113
114    /// Consume a one-time consent. Returns true if successfully consumed,
115    /// false if no one-time entry was available.
116    pub fn consume_one_time(&mut self, node_id: &NodeId, capability: &Capability) -> bool {
117        if let Some(entries) = self.entries.get_mut(node_id) {
118            for entry in entries.iter_mut() {
119                if &entry.capability == capability
120                    && entry.one_time
121                    && !entry.used
122                    && entry.is_valid()
123                {
124                    entry.used = true;
125                    return true;
126                }
127            }
128        }
129        false
130    }
131
132    /// List all granted capabilities for a node (only valid entries).
133    pub fn granted_capabilities(&self, node_id: &NodeId) -> Vec<&Capability> {
134        self.entries
135            .get(node_id)
136            .map(|entries| {
137                entries
138                    .iter()
139                    .filter(|e| e.is_valid())
140                    .map(|e| &e.capability)
141                    .collect()
142            })
143            .unwrap_or_default()
144    }
145
146    /// List all consent entries for a node (including expired/consumed).
147    pub fn list_grants(&self, node_id: &NodeId) -> Vec<&ConsentEntry> {
148        self.entries
149            .get(node_id)
150            .map(|entries| entries.iter().collect())
151            .unwrap_or_default()
152    }
153
154    /// Revoke all consent for a node.
155    pub fn revoke_all(&mut self, node_id: &NodeId) {
156        self.entries.remove(node_id);
157    }
158
159    /// Remove all expired and consumed one-time entries. Returns count removed.
160    pub fn cleanup_expired(&mut self) -> usize {
161        let mut removed = 0;
162        for entries in self.entries.values_mut() {
163            let before = entries.len();
164            entries.retain(|e| e.is_valid());
165            removed += before - entries.len();
166        }
167        // Remove empty node entries
168        self.entries.retain(|_, v| !v.is_empty());
169        removed
170    }
171
172    /// Number of nodes with any consent granted.
173    pub fn node_count(&self) -> usize {
174        self.entries.len()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_consent_grant_and_check() {
184        let mut store = ConsentStore::new();
185        let node = NodeId::new("node-1");
186
187        assert!(!store.is_granted(&node, &Capability::Shell));
188
189        store.grant(&node, Capability::Shell);
190        assert!(store.is_granted(&node, &Capability::Shell));
191        assert!(!store.is_granted(&node, &Capability::FileSystem));
192    }
193
194    #[test]
195    fn test_consent_revoke() {
196        let mut store = ConsentStore::new();
197        let node = NodeId::new("node-1");
198
199        store.grant(&node, Capability::Shell);
200        store.grant(&node, Capability::FileSystem);
201        assert!(store.is_granted(&node, &Capability::Shell));
202
203        store.revoke(&node, &Capability::Shell);
204        assert!(!store.is_granted(&node, &Capability::Shell));
205        assert!(store.is_granted(&node, &Capability::FileSystem));
206    }
207
208    #[test]
209    fn test_consent_revoke_all() {
210        let mut store = ConsentStore::new();
211        let node = NodeId::new("node-1");
212
213        store.grant(&node, Capability::Shell);
214        store.grant(&node, Capability::Screenshot);
215        assert_eq!(store.node_count(), 1);
216
217        store.revoke_all(&node);
218        assert!(!store.is_granted(&node, &Capability::Shell));
219        assert_eq!(store.node_count(), 0);
220    }
221
222    #[test]
223    fn test_consent_granted_capabilities() {
224        let mut store = ConsentStore::new();
225        let node = NodeId::new("node-1");
226
227        store.grant(&node, Capability::Shell);
228        store.grant(&node, Capability::Clipboard);
229        let caps = store.granted_capabilities(&node);
230        assert_eq!(caps.len(), 2);
231    }
232
233    #[test]
234    fn test_consent_multiple_nodes() {
235        let mut store = ConsentStore::new();
236        let n1 = NodeId::new("node-1");
237        let n2 = NodeId::new("node-2");
238
239        store.grant(&n1, Capability::Shell);
240        store.grant(&n2, Capability::FileSystem);
241
242        assert!(store.is_granted(&n1, &Capability::Shell));
243        assert!(!store.is_granted(&n1, &Capability::FileSystem));
244        assert!(store.is_granted(&n2, &Capability::FileSystem));
245        assert_eq!(store.node_count(), 2);
246    }
247
248    // --- New enrichment tests ---
249
250    #[test]
251    fn test_consent_grant_with_expiry() {
252        let mut store = ConsentStore::new();
253        let node = NodeId::new("node-1");
254
255        let entry = store.grant_with_expiry(&node, Capability::Shell, Duration::hours(1));
256        assert!(entry.expires_at.is_some());
257        assert!(!entry.one_time);
258        assert!(store.is_granted(&node, &Capability::Shell));
259    }
260
261    #[test]
262    fn test_consent_expired_denied() {
263        let mut store = ConsentStore::new();
264        let node = NodeId::new("node-1");
265
266        // Grant with negative duration (already expired)
267        store.grant_with_expiry(&node, Capability::Shell, Duration::seconds(-1));
268        assert!(!store.is_granted(&node, &Capability::Shell));
269    }
270
271    #[test]
272    fn test_consent_one_time_grant() {
273        let mut store = ConsentStore::new();
274        let node = NodeId::new("node-1");
275
276        let entry = store.grant_one_time(&node, Capability::Shell);
277        assert!(entry.one_time);
278        assert!(!entry.used);
279        assert!(store.is_granted(&node, &Capability::Shell));
280    }
281
282    #[test]
283    fn test_consent_one_time_consumed() {
284        let mut store = ConsentStore::new();
285        let node = NodeId::new("node-1");
286
287        store.grant_one_time(&node, Capability::Shell);
288        assert!(store.consume_one_time(&node, &Capability::Shell));
289    }
290
291    #[test]
292    fn test_consent_one_time_reuse_denied() {
293        let mut store = ConsentStore::new();
294        let node = NodeId::new("node-1");
295
296        store.grant_one_time(&node, Capability::Shell);
297        assert!(store.consume_one_time(&node, &Capability::Shell));
298        // After consumption, it should no longer be granted
299        assert!(!store.is_granted(&node, &Capability::Shell));
300        // Second consume attempt fails
301        assert!(!store.consume_one_time(&node, &Capability::Shell));
302    }
303
304    #[test]
305    fn test_consent_cleanup_expired() {
306        let mut store = ConsentStore::new();
307        let node = NodeId::new("node-1");
308
309        // Add an already-expired entry
310        store.grant_with_expiry(&node, Capability::Shell, Duration::seconds(-1));
311        // Add a valid entry
312        store.grant(&node, Capability::FileSystem);
313
314        let removed = store.cleanup_expired();
315        assert_eq!(removed, 1);
316        assert!(!store.is_granted(&node, &Capability::Shell));
317        assert!(store.is_granted(&node, &Capability::FileSystem));
318    }
319
320    #[test]
321    fn test_consent_list_grants() {
322        let mut store = ConsentStore::new();
323        let node = NodeId::new("node-1");
324
325        store.grant(&node, Capability::Shell);
326        store.grant_one_time(&node, Capability::FileSystem);
327        store.grant_with_expiry(&node, Capability::Screenshot, Duration::hours(1));
328
329        let grants = store.list_grants(&node);
330        assert_eq!(grants.len(), 3);
331    }
332
333    #[test]
334    fn test_consent_revoke_still_works() {
335        let mut store = ConsentStore::new();
336        let node = NodeId::new("node-1");
337
338        store.grant(&node, Capability::Shell);
339        store.grant_one_time(&node, Capability::Shell);
340        assert!(store.is_granted(&node, &Capability::Shell));
341
342        // Revoke removes ALL entries for that capability
343        store.revoke(&node, &Capability::Shell);
344        assert!(!store.is_granted(&node, &Capability::Shell));
345        assert!(store.list_grants(&node).is_empty());
346    }
347
348    #[test]
349    fn test_consent_multiple_nodes_isolation() {
350        let mut store = ConsentStore::new();
351        let n1 = NodeId::new("node-1");
352        let n2 = NodeId::new("node-2");
353
354        store.grant_one_time(&n1, Capability::Shell);
355        store.grant(&n2, Capability::Shell);
356
357        // Consuming n1's one-time doesn't affect n2
358        store.consume_one_time(&n1, &Capability::Shell);
359        assert!(!store.is_granted(&n1, &Capability::Shell));
360        assert!(store.is_granted(&n2, &Capability::Shell));
361    }
362
363    #[test]
364    fn test_consent_mixed_entries() {
365        let mut store = ConsentStore::new();
366        let node = NodeId::new("node-1");
367
368        // One permanent, one one-time, one expired
369        store.grant(&node, Capability::Shell);
370        store.grant_one_time(&node, Capability::FileSystem);
371        store.grant_with_expiry(&node, Capability::Screenshot, Duration::seconds(-1));
372
373        // Shell: valid (permanent), FileSystem: valid (one-time unused), Screenshot: invalid (expired)
374        let valid = store.granted_capabilities(&node);
375        assert_eq!(valid.len(), 2);
376
377        // All entries still stored (including expired)
378        let all = store.list_grants(&node);
379        assert_eq!(all.len(), 3);
380
381        // Cleanup removes expired
382        let removed = store.cleanup_expired();
383        assert_eq!(removed, 1);
384        assert_eq!(store.list_grants(&node).len(), 2);
385    }
386}