use std::collections::HashMap;
use std::sync::Mutex;
use std::time::SystemTime;
use crate::error::Error;
pub trait ReplayCache: Send + Sync {
fn check_and_insert(&self, assertion_id: &str, expires_at: SystemTime) -> Result<bool, Error>;
}
#[derive(Debug)]
pub struct InMemoryReplayCache {
capacity: usize,
inner: Mutex<HashMap<String, SystemTime>>,
}
impl InMemoryReplayCache {
pub const DEFAULT_CAPACITY: usize = 100_000;
pub fn new(capacity_hint: usize) -> Self {
Self {
capacity: capacity_hint,
inner: Mutex::new(HashMap::with_capacity(capacity_hint)),
}
}
pub fn len(&self) -> usize {
self.inner.lock().map_or(0, |g| g.len())
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl Default for InMemoryReplayCache {
fn default() -> Self {
Self::new(Self::DEFAULT_CAPACITY)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReplayMode {
#[default]
All,
OneTimeUseOnly,
Off,
}
impl ReplayCache for InMemoryReplayCache {
fn check_and_insert(&self, assertion_id: &str, expires_at: SystemTime) -> Result<bool, Error> {
let mut guard = self.inner.lock().map_err(|_err| Error::ReplayCache {
reason: "in-memory replay cache mutex poisoned",
})?;
let now = SystemTime::now();
guard.retain(|_, exp| *exp > now);
if guard.contains_key(assertion_id) {
return Ok(false);
}
if guard.len() >= self.capacity {
return Err(Error::ReplayCacheFull);
}
guard.insert(assertion_id.to_owned(), expires_at);
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
const TEST_CAPACITY: usize = 64;
fn future_expiry(secs: u64) -> SystemTime {
SystemTime::now()
.checked_add(Duration::from_secs(secs))
.expect("future_expiry: fixed offset fits in SystemTime")
}
fn past_expiry(secs: u64) -> SystemTime {
SystemTime::now()
.checked_sub(Duration::from_secs(secs))
.expect("past_expiry: fixed offset fits in SystemTime")
}
#[test]
fn replay_first_time_insert_succeeds() {
let cache = InMemoryReplayCache::new(TEST_CAPACITY);
let inserted = cache
.check_and_insert("_a1", future_expiry(300))
.expect("first insert");
assert!(inserted, "first insert returns true");
assert_eq!(cache.len(), 1);
}
#[test]
fn replay_duplicate_within_ttl_rejected() {
let cache = InMemoryReplayCache::new(TEST_CAPACITY);
let first = cache
.check_and_insert("_a1", future_expiry(300))
.expect("first insert");
assert!(first);
let second = cache
.check_and_insert("_a1", future_expiry(300))
.expect("second insert");
assert!(!second, "duplicate within TTL returns false");
assert_eq!(cache.len(), 1);
}
#[test]
fn replay_after_expiry_succeeds() {
let cache = InMemoryReplayCache::new(TEST_CAPACITY);
let inserted = cache
.check_and_insert("_a1", past_expiry(1))
.expect("insert with past expiry");
assert!(inserted);
let again = cache
.check_and_insert("_a1", future_expiry(300))
.expect("re-insert after expiry");
assert!(
again,
"an entry whose expires_at is in the past must be swept and re-insertable"
);
assert_eq!(cache.len(), 1);
}
#[test]
fn replay_capacity_full_errors() {
let cache = InMemoryReplayCache::new(2);
cache
.check_and_insert("_a", future_expiry(300))
.expect("first");
cache
.check_and_insert("_b", future_expiry(300))
.expect("second");
let err = cache
.check_and_insert("_c", future_expiry(300))
.expect_err("capacity exhausted");
assert!(
matches!(err, Error::ReplayCacheFull),
"expected Error::ReplayCacheFull, got {err:?}"
);
}
#[test]
fn default_constructs_with_default_capacity() {
let cache = InMemoryReplayCache::default();
assert!(cache.is_empty());
cache
.check_and_insert("_a", future_expiry(300))
.expect("default cache accepts inserts");
assert_eq!(cache.len(), 1);
}
#[test]
fn replay_mode_default_is_strict() {
assert_eq!(ReplayMode::default(), ReplayMode::All);
}
#[test]
fn replay_cache_is_object_safe() {
let cache = InMemoryReplayCache::new(TEST_CAPACITY);
let as_dyn: &dyn ReplayCache = &cache;
as_dyn
.check_and_insert("_a", future_expiry(60))
.expect("dyn dispatch works");
}
}