use std::{
collections::HashMap,
sync::{
Mutex, PoisonError,
atomic::{AtomicU64, Ordering},
},
};
pub const WINDOW: u64 = 3600;
pub const MEDIUM: u64 = 5;
pub const HIGH: u64 = 20;
pub const JA4_MEDIUM: u64 = 100;
pub const JA4_HIGH: u64 = 400;
pub trait VelocityStore: Send + Sync {
fn record(&self, key: &str, now: u64);
fn count(&self, key: &str, now: u64, window: u64) -> u64;
}
#[derive(Debug, Default)]
pub struct VelocityTable {
events: Mutex<HashMap<String, Vec<u64>>>,
max_keys: Option<usize>,
dropped: AtomicU64,
}
impl VelocityTable {
pub fn new() -> Self {
Self::default()
}
pub fn with_capacity(max_keys: Option<usize>) -> Self {
Self {
events: Mutex::new(HashMap::new()),
max_keys,
dropped: AtomicU64::new(0),
}
}
pub fn record(&self, key: &str, now: u64) {
let mut events = self.lock();
if let Some(entry) = events.get_mut(key) {
entry.push(now);
return;
}
if let Some(cap) = self.max_keys
&& events.len() >= cap
{
drop(events);
self.dropped.fetch_add(1, Ordering::Relaxed);
return;
}
events.insert(key.to_owned(), vec![now]);
}
pub fn dropped(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
pub fn count(&self, key: &str, now: u64, window: u64) -> u64 {
let mut events = self.lock();
let Some(list) = events.get_mut(key) else {
return 0;
};
let cutoff = now.saturating_sub(window);
list.retain(|&t| t >= cutoff);
list.len() as u64
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Vec<u64>>> {
self.events.lock().unwrap_or_else(PoisonError::into_inner)
}
}
impl VelocityStore for VelocityTable {
fn record(&self, key: &str, now: u64) {
VelocityTable::record(self, key, now);
}
fn count(&self, key: &str, now: u64, window: u64) -> u64 {
VelocityTable::count(self, key, now, window)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VelocityBand {
Low,
Medium,
High,
}
impl VelocityBand {
pub fn as_str(self) -> &'static str {
match self {
VelocityBand::Low => "low",
VelocityBand::Medium => "medium",
VelocityBand::High => "high",
}
}
pub fn classify_with(count: u64, medium: u64, high: u64) -> VelocityBand {
if count >= high {
VelocityBand::High
} else if count >= medium {
VelocityBand::Medium
} else {
VelocityBand::Low
}
}
pub fn classify(count: u64) -> VelocityBand {
Self::classify_with(count, MEDIUM, HIGH)
}
pub fn classify_ja4(count: u64) -> VelocityBand {
Self::classify_with(count, JA4_MEDIUM, JA4_HIGH)
}
}
#[cfg(test)]
mod tests {
use super::{HIGH, MEDIUM, VelocityBand, VelocityTable, WINDOW};
#[test]
fn counts_events_within_the_window() {
let table = VelocityTable::new();
for t in [1_000, 1_100, 1_200, 1_300, 1_400] {
table.record("ip", t);
}
assert_eq!(table.count("ip", 1_400, WINDOW), 5);
}
#[test]
fn events_beyond_the_window_are_pruned() {
let table = VelocityTable::new();
table.record("ip", 1_000);
table.record("ip", 2_000);
let now = 1_000 + WINDOW + 5_000;
table.record("ip", now - 100);
table.record("ip", now - 50);
table.record("ip", now);
assert_eq!(table.count("ip", now, WINDOW), 3);
}
#[test]
fn unseen_key_returns_zero() {
let table = VelocityTable::new();
assert_eq!(table.count("nope", 1_000, WINDOW), 0);
}
#[test]
fn distinct_key_cap_drops_overflow_but_keeps_counting_tracked() {
let table = VelocityTable::with_capacity(Some(2));
table.record("a", 1_000);
table.record("b", 1_000);
table.record("c", 1_000); table.record("c", 1_000); table.record("a", 1_100);
assert_eq!(table.count("a", 1_100, WINDOW), 2);
assert_eq!(table.count("b", 1_100, WINDOW), 1);
assert_eq!(table.count("c", 1_100, WINDOW), 0); assert_eq!(table.dropped(), 2);
}
#[test]
fn classify_uses_documented_thresholds() {
assert_eq!(VelocityBand::classify(0), VelocityBand::Low);
assert_eq!(VelocityBand::classify(MEDIUM - 1), VelocityBand::Low);
assert_eq!(VelocityBand::classify(MEDIUM), VelocityBand::Medium);
assert_eq!(VelocityBand::classify(HIGH - 1), VelocityBand::Medium);
assert_eq!(VelocityBand::classify(HIGH), VelocityBand::High);
assert_eq!(VelocityBand::Low.as_str(), "low");
assert_eq!(VelocityBand::Medium.as_str(), "medium");
assert_eq!(VelocityBand::High.as_str(), "high");
}
#[test]
fn classify_ja4_uses_the_far_higher_thresholds() {
use super::{JA4_HIGH, JA4_MEDIUM};
assert_eq!(VelocityBand::classify_ja4(0), VelocityBand::Low);
assert_eq!(
VelocityBand::classify_ja4(JA4_MEDIUM - 1),
VelocityBand::Low
);
assert_eq!(VelocityBand::classify_ja4(JA4_MEDIUM), VelocityBand::Medium);
assert_eq!(
VelocityBand::classify_ja4(JA4_HIGH - 1),
VelocityBand::Medium
);
assert_eq!(VelocityBand::classify_ja4(JA4_HIGH), VelocityBand::High);
const { assert!(JA4_MEDIUM > HIGH) };
assert_eq!(VelocityBand::classify_ja4(HIGH), VelocityBand::Low);
}
}