Skip to main content

capsec_core/
runtime.rs

1//! Runtime-revocable and time-bounded capability tokens.
2//!
3//! [`RuntimeCap<P>`] wraps a static [`Cap<P>`](crate::cap::Cap) with a shared
4//! revocation flag. [`TimedCap<P>`] wraps a `Cap<P>` with an expiry deadline.
5//! Unlike `Cap<P>`, neither type implements [`Has<P>`](crate::has::Has) — callers
6//! must use [`try_cap()`](RuntimeCap::try_cap) and handle the fallible result explicitly.
7//!
8//! A [`Revoker`] handle is returned alongside each `RuntimeCap` and can be used
9//! to invalidate the capability at any time from any thread.
10
11use crate::cap::Cap;
12use crate::error::CapSecError;
13use crate::permission::Permission;
14use std::marker::PhantomData;
15use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::time::{Duration, Instant};
18
19/// A revocable capability token proving the holder has permission `P`.
20///
21/// Created via [`RuntimeCap::new`], which consumes a [`Cap<P>`] as proof of
22/// possession and returns a `(RuntimeCap<P>, Revoker)` pair.
23///
24/// `!Send + !Sync` by default — use [`make_send`](RuntimeCap::make_send) for
25/// cross-thread transfer. Cloning shares the same revocation state: revoking
26/// one clone revokes all of them.
27pub struct RuntimeCap<P: Permission> {
28    _phantom: PhantomData<P>,
29    // PhantomData<*const ()> makes RuntimeCap !Send + !Sync
30    _not_send: PhantomData<*const ()>,
31    active: Arc<AtomicBool>,
32}
33
34impl<P: Permission> RuntimeCap<P> {
35    /// Creates a revocable capability by consuming a [`Cap<P>`] as proof of possession.
36    ///
37    /// Returns a `(RuntimeCap<P>, Revoker)` pair. The `Revoker` can invalidate
38    /// this capability (and all its clones) from any thread.
39    pub fn new(_cap: Cap<P>) -> (Self, Revoker) {
40        let active = Arc::new(AtomicBool::new(true));
41        let revoker = Revoker {
42            active: Arc::clone(&active),
43        };
44        let cap = Self {
45            _phantom: PhantomData,
46            _not_send: PhantomData,
47            active,
48        };
49        (cap, revoker)
50    }
51
52    /// Attempts to obtain a [`Cap<P>`] from this revocable capability.
53    ///
54    /// Returns `Ok(Cap<P>)` if still active, or `Err(CapSecError::Revoked)` if
55    /// the associated [`Revoker`] has been invoked.
56    pub fn try_cap(&self) -> Result<Cap<P>, CapSecError> {
57        if self.active.load(Ordering::Acquire) {
58            Ok(Cap::new())
59        } else {
60            Err(CapSecError::Revoked)
61        }
62    }
63
64    /// Advisory check — returns `true` if the capability has not been revoked.
65    ///
66    /// The result is immediately stale; do not use for control flow.
67    /// Always use [`try_cap`](RuntimeCap::try_cap) for actual access.
68    pub fn is_active(&self) -> bool {
69        self.active.load(Ordering::Acquire)
70    }
71
72    /// Converts this capability into a [`RuntimeSendCap`] that can cross thread boundaries.
73    ///
74    /// This is an explicit opt-in — you're acknowledging that this capability
75    /// will be used in a multi-threaded context.
76    pub fn make_send(self) -> RuntimeSendCap<P> {
77        RuntimeSendCap {
78            _phantom: PhantomData,
79            active: self.active,
80        }
81    }
82}
83
84impl<P: Permission> Clone for RuntimeCap<P> {
85    fn clone(&self) -> Self {
86        Self {
87            _phantom: PhantomData,
88            _not_send: PhantomData,
89            active: Arc::clone(&self.active),
90        }
91    }
92}
93
94/// A handle that can revoke its associated [`RuntimeCap`] (and all clones).
95///
96/// `Revoker` is `Send + Sync` and `Clone` — multiple owners can hold revokers
97/// to the same capability, and any of them can revoke it from any thread.
98/// Revocation is idempotent: calling [`revoke`](Revoker::revoke) multiple times
99/// is safe and has no additional effect.
100pub struct Revoker {
101    active: Arc<AtomicBool>,
102}
103
104impl Revoker {
105    /// Revokes the associated capability. All subsequent calls to
106    /// [`RuntimeCap::try_cap`] (and clones) will return `Err(CapSecError::Revoked)`.
107    ///
108    /// Idempotent — calling multiple times is safe.
109    pub fn revoke(&self) {
110        self.active.store(false, Ordering::Release);
111    }
112
113    /// Returns `true` if the capability has been revoked.
114    pub fn is_revoked(&self) -> bool {
115        !self.active.load(Ordering::Acquire)
116    }
117}
118
119impl Clone for Revoker {
120    fn clone(&self) -> Self {
121        Self {
122            active: Arc::clone(&self.active),
123        }
124    }
125}
126
127/// A thread-safe revocable capability token.
128///
129/// Created via [`RuntimeCap::make_send`]. Unlike [`RuntimeCap`], this implements
130/// `Send + Sync`, making it usable with `std::thread::spawn`, `tokio::spawn`, etc.
131pub struct RuntimeSendCap<P: Permission> {
132    _phantom: PhantomData<P>,
133    active: Arc<AtomicBool>,
134}
135
136// SAFETY: RuntimeSendCap is explicitly opted into cross-thread transfer via make_send().
137// The inner Arc<AtomicBool> is already Send+Sync; PhantomData<P> is Send+Sync when P is.
138// Permission types are marker traits (ZSTs) that are always Send+Sync.
139unsafe impl<P: Permission> Send for RuntimeSendCap<P> {}
140unsafe impl<P: Permission> Sync for RuntimeSendCap<P> {}
141
142impl<P: Permission> RuntimeSendCap<P> {
143    /// Attempts to obtain a [`Cap<P>`] from this revocable capability.
144    ///
145    /// Returns `Ok(Cap<P>)` if still active, or `Err(CapSecError::Revoked)` if
146    /// the associated [`Revoker`] has been invoked.
147    pub fn try_cap(&self) -> Result<Cap<P>, CapSecError> {
148        if self.active.load(Ordering::Acquire) {
149            Ok(Cap::new())
150        } else {
151            Err(CapSecError::Revoked)
152        }
153    }
154
155    /// Advisory check — returns `true` if the capability has not been revoked.
156    ///
157    /// The result is immediately stale; do not use for control flow.
158    pub fn is_active(&self) -> bool {
159        self.active.load(Ordering::Acquire)
160    }
161}
162
163impl<P: Permission> Clone for RuntimeSendCap<P> {
164    fn clone(&self) -> Self {
165        Self {
166            _phantom: PhantomData,
167            active: Arc::clone(&self.active),
168        }
169    }
170}
171
172/// A time-bounded capability token proving the holder has permission `P`.
173///
174/// Created via [`TimedCap::new`], which consumes a [`Cap<P>`] and a TTL duration.
175/// After the TTL elapses, [`try_cap()`](TimedCap::try_cap) returns
176/// `Err(CapSecError::Expired)`.
177///
178/// `!Send + !Sync` by default — use [`make_send`](TimedCap::make_send) for
179/// cross-thread transfer. Cloning copies the same expiry instant.
180pub struct TimedCap<P: Permission> {
181    _phantom: PhantomData<P>,
182    // PhantomData<*const ()> makes TimedCap !Send + !Sync
183    _not_send: PhantomData<*const ()>,
184    expires_at: Instant,
185}
186
187impl<P: Permission> TimedCap<P> {
188    /// Creates a time-bounded capability by consuming a [`Cap<P>`] as proof of possession.
189    ///
190    /// The capability expires after `ttl` has elapsed from the moment of creation.
191    pub fn new(_cap: Cap<P>, ttl: Duration) -> Self {
192        Self {
193            _phantom: PhantomData,
194            _not_send: PhantomData,
195            expires_at: Instant::now() + ttl,
196        }
197    }
198
199    /// Attempts to obtain a [`Cap<P>`] from this timed capability.
200    ///
201    /// Returns `Ok(Cap<P>)` if the TTL has not elapsed, or `Err(CapSecError::Expired)`
202    /// if the capability has expired.
203    pub fn try_cap(&self) -> Result<Cap<P>, CapSecError> {
204        if Instant::now() < self.expires_at {
205            Ok(Cap::new())
206        } else {
207            Err(CapSecError::Expired)
208        }
209    }
210
211    /// Advisory check — returns `true` if the capability has not yet expired.
212    ///
213    /// The result is immediately stale; do not use for control flow.
214    /// Always use [`try_cap`](TimedCap::try_cap) for actual access.
215    pub fn is_active(&self) -> bool {
216        Instant::now() < self.expires_at
217    }
218
219    /// Returns the remaining duration before expiry.
220    ///
221    /// Returns [`Duration::ZERO`] if the capability has already expired.
222    pub fn remaining(&self) -> Duration {
223        self.expires_at.saturating_duration_since(Instant::now())
224    }
225
226    /// Converts this capability into a [`TimedSendCap`] that can cross thread boundaries.
227    ///
228    /// This is an explicit opt-in — you're acknowledging that this capability
229    /// will be used in a multi-threaded context.
230    pub fn make_send(self) -> TimedSendCap<P> {
231        TimedSendCap {
232            _phantom: PhantomData,
233            expires_at: self.expires_at,
234        }
235    }
236}
237
238impl<P: Permission> Clone for TimedCap<P> {
239    fn clone(&self) -> Self {
240        Self {
241            _phantom: PhantomData,
242            _not_send: PhantomData,
243            expires_at: self.expires_at,
244        }
245    }
246}
247
248/// A thread-safe time-bounded capability token.
249///
250/// Created via [`TimedCap::make_send`]. Unlike [`TimedCap`], this implements
251/// `Send + Sync`, making it usable with `std::thread::spawn`, `tokio::spawn`, etc.
252pub struct TimedSendCap<P: Permission> {
253    _phantom: PhantomData<P>,
254    expires_at: Instant,
255}
256
257// SAFETY: TimedSendCap is explicitly opted into cross-thread transfer via make_send().
258// Instant is Send+Sync; PhantomData<P> is Send+Sync when P is.
259// Permission types are marker traits (ZSTs) that are always Send+Sync.
260unsafe impl<P: Permission> Send for TimedSendCap<P> {}
261unsafe impl<P: Permission> Sync for TimedSendCap<P> {}
262
263impl<P: Permission> TimedSendCap<P> {
264    /// Attempts to obtain a [`Cap<P>`] from this timed capability.
265    ///
266    /// Returns `Ok(Cap<P>)` if the TTL has not elapsed, or `Err(CapSecError::Expired)`
267    /// if the capability has expired.
268    pub fn try_cap(&self) -> Result<Cap<P>, CapSecError> {
269        if Instant::now() < self.expires_at {
270            Ok(Cap::new())
271        } else {
272            Err(CapSecError::Expired)
273        }
274    }
275
276    /// Advisory check — returns `true` if the capability has not yet expired.
277    ///
278    /// The result is immediately stale; do not use for control flow.
279    pub fn is_active(&self) -> bool {
280        Instant::now() < self.expires_at
281    }
282
283    /// Returns the remaining duration before expiry.
284    ///
285    /// Returns [`Duration::ZERO`] if the capability has already expired.
286    pub fn remaining(&self) -> Duration {
287        self.expires_at.saturating_duration_since(Instant::now())
288    }
289}
290
291impl<P: Permission> Clone for TimedSendCap<P> {
292    fn clone(&self) -> Self {
293        Self {
294            _phantom: PhantomData,
295            expires_at: self.expires_at,
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::permission::FsRead;
304    use std::mem::size_of;
305
306    #[test]
307    fn runtime_cap_try_cap_succeeds_when_active() {
308        let root = crate::root::test_root();
309        let cap = root.grant::<FsRead>();
310        let (rcap, _revoker) = RuntimeCap::new(cap);
311        assert!(rcap.try_cap().is_ok());
312    }
313
314    #[test]
315    fn runtime_cap_try_cap_fails_after_revocation() {
316        let root = crate::root::test_root();
317        let cap = root.grant::<FsRead>();
318        let (rcap, revoker) = RuntimeCap::new(cap);
319        revoker.revoke();
320        assert!(matches!(rcap.try_cap(), Err(CapSecError::Revoked)));
321    }
322
323    #[test]
324    fn revoker_is_idempotent() {
325        let root = crate::root::test_root();
326        let cap = root.grant::<FsRead>();
327        let (_rcap, revoker) = RuntimeCap::new(cap);
328        revoker.revoke();
329        revoker.revoke(); // should not panic
330        assert!(revoker.is_revoked());
331    }
332
333    #[test]
334    fn revoker_is_send_and_sync() {
335        fn assert_send_sync<T: Send + Sync>() {}
336        assert_send_sync::<Revoker>();
337    }
338
339    #[test]
340    fn runtime_send_cap_crosses_threads() {
341        let root = crate::root::test_root();
342        let cap = root.grant::<FsRead>();
343        let (rcap, _revoker) = RuntimeCap::new(cap);
344        let send_cap = rcap.make_send();
345
346        std::thread::spawn(move || {
347            assert!(send_cap.try_cap().is_ok());
348        })
349        .join()
350        .unwrap();
351    }
352
353    #[test]
354    fn runtime_send_cap_revocation_crosses_threads() {
355        let root = crate::root::test_root();
356        let cap = root.grant::<FsRead>();
357        let (rcap, revoker) = RuntimeCap::new(cap);
358        let send_cap = rcap.make_send();
359
360        revoker.revoke();
361
362        std::thread::spawn(move || {
363            assert!(matches!(send_cap.try_cap(), Err(CapSecError::Revoked)));
364        })
365        .join()
366        .unwrap();
367    }
368
369    #[test]
370    fn cloned_runtime_cap_shares_revocation() {
371        let root = crate::root::test_root();
372        let cap = root.grant::<FsRead>();
373        let (rcap, revoker) = RuntimeCap::new(cap);
374        let rcap2 = rcap.clone();
375
376        revoker.revoke();
377
378        assert!(matches!(rcap.try_cap(), Err(CapSecError::Revoked)));
379        assert!(matches!(rcap2.try_cap(), Err(CapSecError::Revoked)));
380    }
381
382    #[test]
383    fn runtime_cap_is_small() {
384        assert!(size_of::<RuntimeCap<FsRead>>() <= 2 * size_of::<usize>());
385    }
386
387    #[test]
388    fn timed_cap_succeeds_before_expiry() {
389        let root = crate::root::test_root();
390        let cap = root.grant::<FsRead>();
391        let tcap = TimedCap::new(cap, Duration::from_secs(60));
392        assert!(tcap.try_cap().is_ok());
393    }
394
395    #[test]
396    fn timed_cap_fails_after_expiry() {
397        let root = crate::root::test_root();
398        let cap = root.grant::<FsRead>();
399        let tcap = TimedCap::new(cap, Duration::from_millis(5));
400        std::thread::sleep(Duration::from_millis(50));
401        assert!(matches!(tcap.try_cap(), Err(CapSecError::Expired)));
402    }
403
404    #[test]
405    fn timed_cap_remaining_decreases() {
406        let root = crate::root::test_root();
407        let cap = root.grant::<FsRead>();
408        let tcap = TimedCap::new(cap, Duration::from_secs(60));
409        let r1 = tcap.remaining();
410        std::thread::sleep(Duration::from_millis(10));
411        let r2 = tcap.remaining();
412        assert!(r2 < r1);
413    }
414
415    #[test]
416    fn timed_cap_remaining_is_zero_after_expiry() {
417        let root = crate::root::test_root();
418        let cap = root.grant::<FsRead>();
419        let tcap = TimedCap::new(cap, Duration::from_millis(5));
420        std::thread::sleep(Duration::from_millis(50));
421        assert_eq!(tcap.remaining(), Duration::ZERO);
422    }
423}