1extern crate alloc;
37
38use alloc::collections::BTreeSet;
39use alloc::string::{String, ToString};
40use alloc::sync::Arc;
41
42use arc_swap::ArcSwap;
43use serde::{Deserialize, Serialize};
44
45#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50#[serde(transparent)]
51pub struct RevocationViewSubject(String);
52
53impl RevocationViewSubject {
54 pub fn new(value: impl Into<String>) -> Self {
55 Self(value.into())
56 }
57
58 pub fn as_str(&self) -> &str {
59 &self.0
60 }
61}
62
63impl From<&str> for RevocationViewSubject {
64 fn from(value: &str) -> Self {
65 Self(value.to_string())
66 }
67}
68
69impl From<String> for RevocationViewSubject {
70 fn from(value: String) -> Self {
71 Self(value)
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct RevocationSnapshot {
85 pub epoch: u64,
87 pub root_hash: [u8; 32],
91 pub issued_at_unix_ms: u64,
95 pub revoked: BTreeSet<RevocationViewSubject>,
98}
99
100impl RevocationSnapshot {
101 pub fn empty() -> Self {
105 Self {
106 epoch: 0,
107 root_hash: [0_u8; 32],
108 issued_at_unix_ms: 0,
109 revoked: BTreeSet::new(),
110 }
111 }
112
113 pub fn is_revoked(&self, subject: &RevocationViewSubject) -> bool {
116 self.revoked.contains(subject)
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum RevocationViewError {
126 NonMonotoneEpoch { candidate: u64, installed: u64 },
130}
131
132impl core::fmt::Display for RevocationViewError {
133 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
134 match self {
135 Self::NonMonotoneEpoch {
136 candidate,
137 installed,
138 } => write!(
139 f,
140 "candidate snapshot epoch {candidate} is not strictly greater than installed epoch {installed}"
141 ),
142 }
143 }
144}
145
146impl core::error::Error for RevocationViewError {}
147
148#[derive(Debug)]
152pub struct RevocationView {
153 inner: ArcSwap<RevocationSnapshot>,
154}
155
156impl Default for RevocationView {
157 fn default() -> Self {
158 Self::new()
159 }
160}
161
162impl RevocationView {
163 pub fn new() -> Self {
166 Self {
167 inner: ArcSwap::from_pointee(RevocationSnapshot::empty()),
168 }
169 }
170
171 pub fn load(&self) -> Arc<RevocationSnapshot> {
174 ArcSwap::load_full(&self.inner)
175 }
176
177 pub fn install_if_newer(
184 &self,
185 candidate: RevocationSnapshot,
186 ) -> Result<Arc<RevocationSnapshot>, RevocationViewError> {
187 let candidate_epoch = candidate.epoch;
188 let candidate = Arc::new(candidate);
189
190 loop {
191 let current = self.load();
192 if candidate_epoch <= current.epoch {
193 return Err(RevocationViewError::NonMonotoneEpoch {
194 candidate: candidate_epoch,
195 installed: current.epoch,
196 });
197 }
198
199 let previous = self
200 .inner
201 .compare_and_swap(¤t, Arc::clone(&candidate));
202 if Arc::ptr_eq(¤t, &*previous) {
203 return Ok(current);
204 }
205 }
206 }
207
208 pub fn is_revoked(&self, subject: &RevocationViewSubject) -> bool {
210 self.load().is_revoked(subject)
211 }
212
213 pub fn current_epoch(&self) -> u64 {
216 self.load().epoch
217 }
218}
219
220#[cfg(test)]
221#[allow(clippy::unwrap_used, clippy::expect_used)]
222mod tests {
223 extern crate std;
224
225 use self::std::sync::Barrier;
226 use self::std::thread;
227 use super::*;
228 use alloc::vec;
229 use alloc::vec::Vec;
230
231 fn snapshot(epoch: u64, revoked: &[&str]) -> RevocationSnapshot {
232 let revoked_set = revoked
233 .iter()
234 .copied()
235 .map(RevocationViewSubject::from)
236 .collect();
237 RevocationSnapshot {
238 epoch,
239 root_hash: [(epoch as u8); 32],
240 issued_at_unix_ms: 1_700_000_000_000 + epoch,
241 revoked: revoked_set,
242 }
243 }
244
245 #[test]
246 fn new_view_holds_empty_sentinel() {
247 let view = RevocationView::new();
248 let loaded = view.load();
249 assert_eq!(*loaded, RevocationSnapshot::empty());
250 assert_eq!(view.current_epoch(), 0);
251 }
252
253 #[test]
254 fn install_if_newer_advances_monotonically() {
255 let view = RevocationView::new();
256 let prev = view.install_if_newer(snapshot(1, &["alice"])).unwrap();
257 assert_eq!(prev.epoch, 0);
258 let prev2 = view
259 .install_if_newer(snapshot(5, &["alice", "bob"]))
260 .unwrap();
261 assert_eq!(prev2.epoch, 1);
262 assert_eq!(view.current_epoch(), 5);
263 assert!(view.is_revoked(&RevocationViewSubject::from("alice")));
264 assert!(view.is_revoked(&RevocationViewSubject::from("bob")));
265 assert!(!view.is_revoked(&RevocationViewSubject::from("carol")));
266 }
267
268 #[test]
269 fn install_if_newer_rejects_equal_epoch() {
270 let view = RevocationView::new();
271 view.install_if_newer(snapshot(1, &["alice"])).unwrap();
272 let err = view
273 .install_if_newer(snapshot(1, &["alice", "bob"]))
274 .expect_err("equal epoch must fail closed");
275 assert_eq!(
276 err,
277 RevocationViewError::NonMonotoneEpoch {
278 candidate: 1,
279 installed: 1
280 }
281 );
282 assert!(!view.is_revoked(&RevocationViewSubject::from("bob")));
284 }
285
286 #[test]
287 fn install_if_newer_rejects_stale_epoch() {
288 let view = RevocationView::new();
289 view.install_if_newer(snapshot(5, &["alice"])).unwrap();
290 let err = view
291 .install_if_newer(snapshot(3, &["dave"]))
292 .expect_err("stale epoch must fail closed");
293 assert_eq!(
294 err,
295 RevocationViewError::NonMonotoneEpoch {
296 candidate: 3,
297 installed: 5
298 }
299 );
300 assert!(!view.is_revoked(&RevocationViewSubject::from("dave")));
301 }
302
303 #[test]
304 fn install_if_newer_is_atomic_across_concurrent_writers() {
305 let view = Arc::new(RevocationView::new());
306 let barrier = Arc::new(Barrier::new(8));
307 let mut handles = Vec::new();
308
309 for epoch in 1..=8_u64 {
310 let view = Arc::clone(&view);
311 let barrier = Arc::clone(&barrier);
312 handles.push(thread::spawn(move || {
313 barrier.wait();
314 let _ = view.install_if_newer(snapshot(epoch, &["alice"]));
315 }));
316 }
317
318 for handle in handles {
319 handle.join().unwrap();
320 }
321
322 assert_eq!(view.current_epoch(), 8);
323 assert!(view.is_revoked(&RevocationViewSubject::from("alice")));
324 let err = view
325 .install_if_newer(snapshot(7, &["bob"]))
326 .expect_err("stale concurrent write must fail closed");
327 assert_eq!(
328 err,
329 RevocationViewError::NonMonotoneEpoch {
330 candidate: 7,
331 installed: 8
332 }
333 );
334 assert!(!view.is_revoked(&RevocationViewSubject::from("bob")));
335 }
336
337 #[test]
338 fn snapshot_round_trips_via_serde() {
339 let s = snapshot(7, &["alice", "bob"]);
340 let bytes = serde_json::to_vec(&s).unwrap();
341 let decoded: RevocationSnapshot = serde_json::from_slice(&bytes).unwrap();
342 assert_eq!(decoded, s);
343 }
344
345 #[test]
346 fn snapshot_deny_unknown_fields() {
347 let s = snapshot(2, &["alice"]);
348 let mut value: serde_json::Value = serde_json::to_value(&s).unwrap();
349 value
350 .as_object_mut()
351 .unwrap()
352 .insert("extra".to_string(), serde_json::Value::Bool(true));
353 let bytes = serde_json::to_vec(&value).unwrap();
354 let parsed: Result<RevocationSnapshot, _> = serde_json::from_slice(&bytes);
355 assert!(parsed.is_err(), "unknown fields must be rejected");
356 }
357
358 #[test]
359 fn empty_sentinel_is_default_install_target() {
360 let view = RevocationView::default();
361 assert_eq!(*view.load(), RevocationSnapshot::empty());
362 }
363
364 #[test]
365 fn snapshot_preserves_revoked_ordering() {
366 let s = snapshot(1, &["delta", "alpha", "charlie", "bravo"]);
367 let collected: Vec<String> = s.revoked.iter().map(|k| k.as_str().to_string()).collect();
368 assert_eq!(
369 collected,
370 vec![
371 String::from("alpha"),
372 String::from("bravo"),
373 String::from("charlie"),
374 String::from("delta"),
375 ]
376 );
377 }
378}