atd_runtime/ucan/revocation.rs
1//! SP-capability-v2 revocation-store trait + reference impl.
2//!
3//! - Trait [`UcanRevocationStore`] (read-only `is_revoked`) is the
4//! contract the verifier (Phase B.2) consults on every chain link.
5//! - [`InMemoryUcanRevocationStore`] (Phase E) is the reference impl
6//! for tests + small deployments. Adopters wrap their own revocation
7//! table behind the same trait (celia's `consent.status='revoked'`
8//! rows + new `ucan_cid` index — spec §6).
9//!
10//! Design choice: `revoke()` is **inherent** on `InMemoryUcanRevocationStore`
11//! rather than required on the trait. Rationale — production adopters
12//! revoke via adopter-specific paths (celia Tauri command + recursive
13//! SQL cascade; future atd-ref-server admin CLI; etc.) whose signatures
14//! differ. Keeping the trait read-only matches the verifier's actual
15//! need, and avoids forcing every wrapper to expose a mutator it
16//! doesn't have. SP-capability-v2 §4.7 leaves "how revocations get
17//! recorded" deliberately adopter-side.
18//!
19//! Spec: `docs/archive/superpowers/specs/2026-05-11-sp-capability-v2-design.md` §4.7
20
21use std::collections::HashSet;
22use std::fmt::Debug;
23use std::sync::{Arc, RwLock};
24
25/// A store that can answer "has this UCAN been revoked?" for a CID.
26///
27/// CID format in v1: SHA-256(jwt_compact) hex-encoded (see
28/// [`super::compute_cid`]). The verifier computes this on every link;
29/// the store decides authoritatively.
30///
31/// Implementations must be `Send + Sync` because the verifier runs on
32/// the per-connection task and the store may be shared via
33/// `Arc<dyn UcanRevocationStore>`. `Debug` because [`super::VerifyConfig`]
34/// is `Clone` and propagates the trait object through error contexts.
35pub trait UcanRevocationStore: Send + Sync + Debug {
36 /// Returns `true` if the CID is in the revocation set.
37 fn is_revoked(&self, ucan_cid: &str) -> bool;
38}
39
40/// Reference revocation store: an `Arc<RwLock<HashSet<String>>>` of
41/// revoked CIDs. Suitable for tests and small in-process deployments.
42///
43/// Concurrency: every read takes a shared lock; revocations take an
44/// exclusive lock and run in O(1) average. The whole structure clones
45/// cheaply (`Arc`) so multiple subsystems (broker, dispatch, Tauri
46/// command) can share one revocation surface.
47///
48/// **Not** persistent — restarting the host process empties the set.
49/// Adopters that need durable revocation (celia) back the trait with
50/// their own SQLite-or-similar store.
51#[derive(Debug, Default, Clone)]
52pub struct InMemoryUcanRevocationStore {
53 revoked: Arc<RwLock<HashSet<String>>>,
54}
55
56impl InMemoryUcanRevocationStore {
57 pub fn new() -> Self {
58 Self::default()
59 }
60
61 /// Mark a CID as revoked. Idempotent — revoking the same CID twice
62 /// is a no-op. Once revoked, [`Self::is_revoked`] returns `true`
63 /// for the lifetime of the store (no un-revoke API by design;
64 /// administrators issue a fresh UCAN instead).
65 pub fn revoke(&self, ucan_cid: impl Into<String>) {
66 self.revoked
67 .write()
68 .expect("InMemoryUcanRevocationStore lock poisoned")
69 .insert(ucan_cid.into());
70 }
71
72 /// Number of revoked CIDs currently in the store. Mostly useful
73 /// for test assertions + diagnostics; production code should not
74 /// rely on this for policy.
75 pub fn len(&self) -> usize {
76 self.revoked
77 .read()
78 .expect("InMemoryUcanRevocationStore lock poisoned")
79 .len()
80 }
81
82 pub fn is_empty(&self) -> bool {
83 self.len() == 0
84 }
85}
86
87impl UcanRevocationStore for InMemoryUcanRevocationStore {
88 fn is_revoked(&self, ucan_cid: &str) -> bool {
89 self.revoked
90 .read()
91 .expect("InMemoryUcanRevocationStore lock poisoned")
92 .contains(ucan_cid)
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 //! Phase E unit tests. End-to-end propagation (revoke a chain link
99 //! → verifier rejects descendant chain) is covered by
100 //! `verify::tests::revoked_cid_rejects` (uses `MockRevocationStore`)
101 //! plus a new propagation test below that exercises the real
102 //! `InMemoryUcanRevocationStore` against a 3-link chain.
103
104 use super::*;
105
106 #[test]
107 fn empty_store_reports_nothing_revoked() {
108 let s = InMemoryUcanRevocationStore::new();
109 assert!(!s.is_revoked("bafy-cid-anything"));
110 assert!(s.is_empty());
111 }
112
113 #[test]
114 fn revoke_then_is_revoked_returns_true() {
115 let s = InMemoryUcanRevocationStore::new();
116 s.revoke("cid-abc-123");
117 assert!(s.is_revoked("cid-abc-123"));
118 assert!(!s.is_revoked("cid-xyz-999"));
119 assert_eq!(s.len(), 1);
120 }
121
122 #[test]
123 fn revoke_is_idempotent() {
124 let s = InMemoryUcanRevocationStore::new();
125 s.revoke("cid-A");
126 s.revoke("cid-A");
127 s.revoke("cid-A");
128 assert_eq!(s.len(), 1);
129 assert!(s.is_revoked("cid-A"));
130 }
131
132 #[test]
133 fn store_clones_share_state_via_arc() {
134 // Cloning the store should share the underlying revocation set
135 // (Arc semantics) — a revoke on one handle is visible on the
136 // other. Important for the dispatch ↔ broker sharing pattern.
137 let s = InMemoryUcanRevocationStore::new();
138 let s2 = s.clone();
139 s.revoke("cid-X");
140 assert!(s2.is_revoked("cid-X"));
141 }
142
143 #[test]
144 fn store_works_through_trait_object() {
145 // The verifier holds `Option<Arc<dyn UcanRevocationStore>>`.
146 // Confirm dispatch through the dyn boundary.
147 let inner = Arc::new(InMemoryUcanRevocationStore::new());
148 inner.revoke("cid-T");
149 let dyn_store: Arc<dyn UcanRevocationStore> = inner;
150 assert!(dyn_store.is_revoked("cid-T"));
151 assert!(!dyn_store.is_revoked("cid-other"));
152 }
153}