Lines
100 %
Functions
56.38 %
Branches
//! Bounded sliding-window throttles for authentication attempts.
//!
//! Pure state plus an injected clock, so tests drive the time axis instead of
//! sleeping. Deliberately in-process: a shared backend belongs behind
//! [`Bucket`]'s interface, not woven through its callers, and until one exists
//! the effective limit is `replicas × limit` — which is why the surfaces using
//! this run single-replica.
use std::collections::HashMap;
use std::hash::Hash;
use std::time::{Duration, Instant};
#[cfg(test)]
mod tests;
/// "What time is it now", injected so tests need not sleep.
pub trait Clock {
fn now(&self) -> Instant;
}
/// Delegates to [`Instant::now`].
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> Instant {
Instant::now()
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny,
impl Decision {
#[must_use]
pub const fn is_denied(self) -> bool {
matches!(self, Self::Deny)
/// Throttle shape: how many attempts, over what window, across how many keys.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
/// Attempts one key may make inside `window` before being refused.
pub max_attempts: u32,
/// Width of the sliding window, measured from each attempt.
pub window: Duration,
/// Distinct keys held at once. Bounding this is not an optimization: the
/// key is derived from the peer, so an unbounded map is a memory DoS
/// reachable by anyone who can open connections.
pub capacity: usize,
struct Entry {
attempts: Vec<Instant>,
last_seen: Instant,
/// A sliding-window counter per key, holding at most [`Limits::capacity`] keys.
pub struct Bucket<K, C = SystemClock> {
entries: HashMap<K, Entry>,
limits: Limits,
clock: C,
impl<K: Eq + Hash + Clone> Bucket<K, SystemClock> {
pub fn new(limits: Limits) -> Self {
Self::with_clock(limits, SystemClock)
impl<K: Eq + Hash + Clone, C: Clock> Bucket<K, C> {
pub fn with_clock(limits: Limits, clock: C) -> Self {
Self {
entries: HashMap::new(),
limits,
clock,
/// Records an attempt for `key` and says whether it may proceed.
pub fn check(&mut self, key: &K) -> Decision {
let now = self.clock.now();
let window = self.limits.window;
if !self.entries.contains_key(key) {
self.make_room(now);
let entry = self.entries.entry(key.clone()).or_insert_with(|| Entry {
attempts: Vec::new(),
last_seen: now,
});
entry.last_seen = now;
entry.attempts.retain(|t| now.duration_since(*t) < window);
let seen = u32::try_from(entry.attempts.len()).unwrap_or(u32::MAX);
if seen >= self.limits.max_attempts {
return Decision::Deny;
entry.attempts.push(now);
Decision::Allow
/// Frees a slot for a newcomer: drops keys whose window has fully elapsed,
/// then, if that was not enough, the least-recently-seen key.
///
/// Evicting rather than refusing is deliberate. Refusing when full would
/// let anyone who can fill the map deny service to every other client — a
/// far cheaper attack than the credential stuffing this exists to stop.
fn make_room(&mut self, now: Instant) {
if self.entries.len() < self.limits.capacity {
return;
self.entries
.retain(|_, e| now.duration_since(e.last_seen) < window);
if let Some(stale) = self
.entries
.iter()
.min_by_key(|(_, e)| e.last_seen)
.map(|(k, _)| k.clone())
{
self.entries.remove(&stale);
/// Keys currently tracked. Exposed for metrics and tests.
pub fn tracked(&self) -> usize {
self.entries.len()