#![cfg(feature = "nip98-replay")]
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use lru::LruCache;
use tokio::sync::Mutex;
pub use super::replay_store::{ReplayError, ReplayStore};
pub const DEFAULT_TTL_SECS: u64 = 120;
pub const DEFAULT_MAX_SIZE: usize = 10_000;
pub const ENV_TTL_SECS: &str = "SOLID_POD_NIP98_REPLAY_TTL_SECS";
pub const ENV_MAX_SIZE: &str = "SOLID_POD_NIP98_REPLAY_MAX_SIZE";
#[derive(Debug, Clone)]
pub struct Nip98ReplayCache {
inner: Arc<Mutex<Inner>>,
ttl: Duration,
max_size: usize,
}
#[derive(Debug)]
struct Inner {
entries: LruCache<String, Instant>,
}
impl Nip98ReplayCache {
pub fn from_env() -> Self {
let ttl_secs = std::env::var(ENV_TTL_SECS)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_TTL_SECS);
let max_size = std::env::var(ENV_MAX_SIZE)
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_MAX_SIZE);
Self::with_config(Duration::from_secs(ttl_secs), max_size)
}
pub fn with_config(ttl: Duration, max_size: usize) -> Self {
let cap = NonZeroUsize::new(max_size.max(1)).expect("clamped to >= 1 above");
Self {
inner: Arc::new(Mutex::new(Inner {
entries: LruCache::new(cap),
})),
ttl,
max_size: max_size.max(1),
}
}
pub fn ttl(&self) -> Duration {
self.ttl
}
pub fn max_size(&self) -> usize {
self.max_size
}
pub async fn len(&self) -> usize {
self.inner.lock().await.entries.len()
}
pub async fn is_empty(&self) -> bool {
self.len().await == 0
}
pub async fn evict_expired(&self) -> usize {
let now = Instant::now();
let mut guard = self.inner.lock().await;
let expired: Vec<String> = guard
.entries
.iter()
.filter_map(|(id, seen)| {
if now.saturating_duration_since(*seen) >= self.ttl {
Some(id.clone())
} else {
None
}
})
.collect();
let removed = expired.len();
for id in expired {
guard.entries.pop(&id);
}
removed
}
pub fn spawn_evictor(self, period: Duration) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(period);
ticker.tick().await; loop {
ticker.tick().await;
let _ = self.evict_expired().await;
}
})
}
}
#[async_trait]
impl ReplayStore for Nip98ReplayCache {
async fn check_and_record(&self, event_id: &str) -> Result<(), ReplayError> {
let now = Instant::now();
let mut guard = self.inner.lock().await;
if let Some(first_seen) = guard.entries.peek(event_id).copied() {
if now.saturating_duration_since(first_seen) < self.ttl {
return Err(ReplayError::Replayed { ttl: self.ttl });
}
}
guard.entries.put(event_id.to_string(), now);
Ok(())
}
}
impl Default for Nip98ReplayCache {
fn default() -> Self {
Self::with_config(Duration::from_secs(DEFAULT_TTL_SECS), DEFAULT_MAX_SIZE)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn first_sighting_accepts_replay_rejects() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(60), 8);
let id = "a".repeat(64);
assert!(cache.check_and_record(&id).await.is_ok());
let err = cache.check_and_record(&id).await.unwrap_err();
assert!(matches!(err, ReplayError::Replayed { .. }));
assert!(cache.check_and_record(&"b".repeat(64)).await.is_ok());
}
#[tokio::test]
async fn expired_entry_treated_as_fresh() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(0), 8);
let id = "c".repeat(64);
assert!(cache.check_and_record(&id).await.is_ok());
assert!(cache.check_and_record(&id).await.is_ok());
}
#[tokio::test]
async fn clones_share_storage() {
let a = Nip98ReplayCache::with_config(Duration::from_secs(60), 8);
let b = a.clone();
let id = "d".repeat(64);
assert!(a.check_and_record(&id).await.is_ok());
assert!(b.check_and_record(&id).await.is_err());
}
#[tokio::test]
async fn trait_object_dispatch_rejects_replay() {
let store: Arc<dyn ReplayStore> =
Arc::new(Nip98ReplayCache::with_config(Duration::from_secs(60), 8));
let id = "e".repeat(64);
assert!(store.check_and_record(&id).await.is_ok());
assert!(matches!(
store.check_and_record(&id).await.unwrap_err(),
ReplayError::Replayed { .. }
));
}
#[tokio::test]
async fn capacity_eviction_is_bounded() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(600), 2);
cache.check_and_record("id-1").await.unwrap();
cache.check_and_record("id-2").await.unwrap();
cache.check_and_record("id-3").await.unwrap();
assert_eq!(cache.len().await, 2);
assert!(cache.check_and_record("id-1").await.is_ok());
}
}