use std::collections::HashMap;
use std::sync::Arc;
use chrono::{DateTime, Duration, Utc};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use tokio::sync::Mutex;
pub const MAX_PER_WINDOW: usize = 60;
pub const WINDOW_SECS: i64 = 60;
#[derive(Clone)]
pub struct PublishRateLimiter {
inner: Arc<Mutex<HashMap<[u8; 32], Vec<DateTime<Utc>>>>>,
key: Arc<[u8; 32]>,
}
impl std::fmt::Debug for PublishRateLimiter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PublishRateLimiter(..)")
}
}
impl PublishRateLimiter {
#[must_use]
pub fn new(key: [u8; 32]) -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
key: Arc::new(key),
}
}
fn hash(&self, did: &str) -> [u8; 32] {
let mut mac =
<Hmac<Sha256>>::new_from_slice(&*self.key).expect("HMAC accepts any key size");
mac.update(did.as_bytes());
mac.finalize().into_bytes().into()
}
pub async fn check_and_record(&self, did: &str, now: DateTime<Utc>) -> Result<(), u64> {
let cutoff = now - Duration::seconds(WINDOW_SECS);
let k = self.hash(did);
let mut map = self.inner.lock().await;
let hits = map.entry(k).or_default();
hits.retain(|t| *t > cutoff);
if hits.len() >= MAX_PER_WINDOW {
return Err(retry_after_secs(hits, now));
}
hits.push(now);
if map.len() > 1024 {
map.retain(|_, hits| hits.iter().any(|t| *t > cutoff));
}
Ok(())
}
}
fn retry_after_secs(hits: &[DateTime<Utc>], now: DateTime<Utc>) -> u64 {
let Some(oldest) = hits.iter().min() else {
return 1;
};
let wait_ms = (*oldest + Duration::seconds(WINDOW_SECS) - now).num_milliseconds();
let secs = u64::try_from(wait_ms).unwrap_or(0).div_ceil(1000);
secs.max(1)
}
#[cfg(test)]
mod tests {
use super::*;
fn limiter() -> PublishRateLimiter {
PublishRateLimiter::new([7u8; 32])
}
#[tokio::test]
async fn allows_up_to_the_limit_then_refuses() {
let l = limiter();
let now = Utc::now();
for i in 0..MAX_PER_WINDOW {
assert!(
l.check_and_record("did:key:zA", now).await.is_ok(),
"call {i}"
);
}
assert!(l.check_and_record("did:key:zA", now).await.is_err());
}
#[tokio::test]
async fn one_member_at_the_limit_does_not_block_another() {
let l = limiter();
let now = Utc::now();
for _ in 0..MAX_PER_WINDOW {
let _ = l.check_and_record("did:key:zA", now).await;
}
assert!(l.check_and_record("did:key:zA", now).await.is_err());
assert!(l.check_and_record("did:key:zB", now).await.is_ok());
}
#[tokio::test]
async fn the_window_slides() {
let l = limiter();
let start = Utc::now();
for _ in 0..MAX_PER_WINDOW {
let _ = l.check_and_record("did:key:zA", start).await;
}
assert!(l.check_and_record("did:key:zA", start).await.is_err());
let later = start + Duration::seconds(WINDOW_SECS + 1);
assert!(l.check_and_record("did:key:zA", later).await.is_ok());
}
#[tokio::test]
async fn a_refusal_does_not_extend_the_window() {
let l = limiter();
let start = Utc::now();
for _ in 0..MAX_PER_WINDOW {
let _ = l.check_and_record("did:key:zA", start).await;
}
for s in 1..WINDOW_SECS {
assert!(
l.check_and_record("did:key:zA", start + Duration::seconds(s))
.await
.is_err()
);
}
let after = start + Duration::seconds(WINDOW_SECS + 1);
assert!(l.check_and_record("did:key:zA", after).await.is_ok());
}
#[tokio::test]
async fn a_refusal_names_when_the_allowance_returns() {
let l = limiter();
let start = Utc::now();
for _ in 0..MAX_PER_WINDOW {
let _ = l.check_and_record("did:key:zA", start).await;
}
assert_eq!(
l.check_and_record("did:key:zA", start).await,
Err(WINDOW_SECS as u64)
);
let mid = start + Duration::milliseconds(20_500);
assert_eq!(
l.check_and_record("did:key:zA", mid).await,
Err(WINDOW_SECS as u64 - 20)
);
let nearly = start + Duration::milliseconds(WINDOW_SECS * 1000 - 500);
assert_eq!(l.check_and_record("did:key:zA", nearly).await, Err(1));
let at_edge = start + Duration::seconds(WINDOW_SECS);
assert!(l.check_and_record("did:key:zA", at_edge).await.is_ok());
}
#[tokio::test]
async fn keys_are_hashed_not_stored_plain() {
let l = limiter();
let _ = l.check_and_record("did:key:zAlice", Utc::now()).await;
let map = l.inner.lock().await;
let k = map.keys().next().expect("one entry");
assert_ne!(&k[..], b"did:key:zAlice".as_slice());
assert_eq!(k.len(), 32);
}
}