use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplayCheck {
Novel,
Replay,
}
pub struct XrpcReplayCache {
inner: Mutex<HashMap<(String, String), Instant>>,
ttl: Duration,
clock: Arc<dyn Fn() -> Instant + Send + Sync>,
}
impl XrpcReplayCache {
pub fn new(ttl: Duration) -> Self {
Self::with_clock(ttl, Arc::new(Instant::now))
}
pub(crate) fn with_clock(ttl: Duration, clock: Arc<dyn Fn() -> Instant + Send + Sync>) -> Self {
Self {
inner: Mutex::new(HashMap::new()),
ttl,
clock,
}
}
pub fn check_and_insert(&self, iss: &str, jti: &str) -> ReplayCheck {
let now = (self.clock)();
let mut guard = self.inner.lock().unwrap();
guard.retain(|_, expiry| *expiry > now);
let key = (iss.to_string(), jti.to_string());
if guard.contains_key(&key) {
return ReplayCheck::Replay;
}
guard.insert(key, now + self.ttl);
ReplayCheck::Novel
}
#[cfg(test)]
pub(crate) fn size(&self) -> usize {
self.inner.lock().unwrap().len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct TestClock {
base: Instant,
offset_ms: AtomicU64,
}
impl TestClock {
fn new() -> Arc<Self> {
Arc::new(Self {
base: Instant::now(),
offset_ms: AtomicU64::new(0),
})
}
fn advance_ms(&self, delta: u64) {
self.offset_ms.fetch_add(delta, Ordering::SeqCst);
}
fn closure(self: &Arc<Self>) -> Arc<dyn Fn() -> Instant + Send + Sync> {
let me = Arc::clone(self);
Arc::new(move || me.base + Duration::from_millis(me.offset_ms.load(Ordering::SeqCst)))
}
}
#[test]
fn novel_pair_returns_novel_then_replay() {
let cache = XrpcReplayCache::new(Duration::from_secs(90));
assert_eq!(
cache.check_and_insert("did:plc:a", "jti-1"),
ReplayCheck::Novel
);
assert_eq!(
cache.check_and_insert("did:plc:a", "jti-1"),
ReplayCheck::Replay
);
}
#[test]
fn different_iss_same_jti_both_accepted() {
let cache = XrpcReplayCache::new(Duration::from_secs(90));
assert_eq!(
cache.check_and_insert("did:plc:a", "shared-jti"),
ReplayCheck::Novel
);
assert_eq!(
cache.check_and_insert("did:plc:b", "shared-jti"),
ReplayCheck::Novel
);
}
#[test]
fn ttl_expiry_clears_entry() {
let clock = TestClock::new();
let cache = XrpcReplayCache::with_clock(Duration::from_secs(60), clock.closure());
assert_eq!(
cache.check_and_insert("did:plc:a", "jti-1"),
ReplayCheck::Novel
);
clock.advance_ms(61_000);
assert_eq!(
cache.check_and_insert("did:plc:a", "jti-1"),
ReplayCheck::Novel,
"expired entry should accept the same (iss, jti) again"
);
}
#[test]
fn lazy_eviction_drops_expired_entries() {
let clock = TestClock::new();
let cache = XrpcReplayCache::with_clock(Duration::from_secs(30), clock.closure());
for i in 0..5 {
cache.check_and_insert("did:plc:a", &format!("jti-{i}"));
}
assert_eq!(cache.size(), 5);
clock.advance_ms(31_000);
cache.check_and_insert("did:plc:a", "jti-fresh");
assert_eq!(
cache.size(),
1,
"expired entries dropped; only the fresh one remains"
);
}
#[test]
fn entry_within_ttl_window_is_replay() {
let clock = TestClock::new();
let cache = XrpcReplayCache::with_clock(Duration::from_secs(60), clock.closure());
cache.check_and_insert("did:plc:a", "jti-1");
clock.advance_ms(50_000);
assert_eq!(
cache.check_and_insert("did:plc:a", "jti-1"),
ReplayCheck::Replay,
"still within TTL window — replay"
);
}
}