use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::RwLock;
use crate::error::AppError;
pub const MAX_GLOBAL_PENDING: u64 = 10_000;
#[derive(Debug)]
pub struct PendingChallengeTracker {
per_did: RwLock<HashMap<String, Arc<AtomicU64>>>,
global: AtomicU64,
global_cap: u64,
}
impl Default for PendingChallengeTracker {
fn default() -> Self {
Self::with_global_cap(MAX_GLOBAL_PENDING)
}
}
impl PendingChallengeTracker {
pub fn new() -> Self {
Self::default()
}
pub fn with_global_cap(global_cap: u64) -> Self {
Self {
per_did: RwLock::new(HashMap::new()),
global: AtomicU64::new(0),
global_cap,
}
}
pub async fn try_issue(&self, did: &str, per_did_cap: u64) -> Result<(), AppError> {
let counter = {
let read = self.per_did.read().await;
if let Some(c) = read.get(did) {
c.clone()
} else {
drop(read);
let mut write = self.per_did.write().await;
write
.entry(did.to_string())
.or_insert_with(|| Arc::new(AtomicU64::new(0)))
.clone()
}
};
if self.global.load(Ordering::Relaxed) >= self.global_cap {
return Err(AppError::Validation(format!(
"global pending-challenge cap reached ({} concurrent); try again later",
self.global_cap,
)));
}
if counter.load(Ordering::Relaxed) >= per_did_cap {
return Err(AppError::Validation(format!(
"too many pending challenges for this DID (>= {per_did_cap}); try again later",
)));
}
counter.fetch_add(1, Ordering::Relaxed);
self.global.fetch_add(1, Ordering::Relaxed);
Ok(())
}
pub async fn release(&self, did: &str) {
let counter = {
let read = self.per_did.read().await;
read.get(did).cloned()
};
if let Some(c) = counter {
let prev = c.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
Some(v.saturating_sub(1))
});
if prev.is_ok() {
self.global
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
Some(v.saturating_sub(1))
})
.ok();
}
}
}
#[cfg(test)]
pub async fn count_for(&self, did: &str) -> u64 {
let read = self.per_did.read().await;
read.get(did)
.map(|c| c.load(Ordering::Relaxed))
.unwrap_or(0)
}
#[cfg(test)]
pub fn global_count(&self) -> u64 {
self.global.load(Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn issues_within_caps() {
let t = PendingChallengeTracker::with_global_cap(100);
for _ in 0..5 {
t.try_issue("did:example:a", 10).await.unwrap();
}
assert_eq!(t.count_for("did:example:a").await, 5);
assert_eq!(t.global_count(), 5);
}
#[tokio::test]
async fn per_did_cap_rejects_excess() {
let t = PendingChallengeTracker::with_global_cap(100);
for _ in 0..3 {
t.try_issue("did:example:a", 3).await.unwrap();
}
let err = t.try_issue("did:example:a", 3).await.unwrap_err();
assert!(matches!(err, AppError::Validation(ref m) if m.contains("DID")));
}
#[tokio::test]
async fn global_cap_rejects_sweep_of_distinct_dids() {
let t = PendingChallengeTracker::with_global_cap(5);
for i in 0..5 {
t.try_issue(&format!("did:example:{i}"), 10).await.unwrap();
}
assert_eq!(t.global_count(), 5);
let err = t.try_issue("did:example:6", 10).await.unwrap_err();
assert!(matches!(err, AppError::Validation(ref m) if m.contains("global")));
}
#[tokio::test]
async fn release_decrements_both_counters() {
let t = PendingChallengeTracker::with_global_cap(100);
t.try_issue("did:example:a", 10).await.unwrap();
t.try_issue("did:example:a", 10).await.unwrap();
assert_eq!(t.count_for("did:example:a").await, 2);
assert_eq!(t.global_count(), 2);
t.release("did:example:a").await;
assert_eq!(t.count_for("did:example:a").await, 1);
assert_eq!(t.global_count(), 1);
}
#[tokio::test]
async fn release_saturates_at_zero() {
let t = PendingChallengeTracker::with_global_cap(100);
t.try_issue("did:example:a", 10).await.unwrap();
t.release("did:example:a").await;
t.release("did:example:a").await; t.release("did:example:a").await; assert_eq!(t.count_for("did:example:a").await, 0);
assert_eq!(t.global_count(), 0);
}
#[tokio::test]
async fn release_unknown_did_noop() {
let t = PendingChallengeTracker::with_global_cap(100);
t.release("did:example:never-seen").await;
assert_eq!(t.global_count(), 0);
}
}