nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! Per-IP sliding-window rate limits (in-process; no external deps).

use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};

/// Fixed-window counter per IP with stale-bucket eviction.
#[derive(Debug)]
pub struct IpRateLimiter {
    max_events: u32,
    window: Duration,
    max_tracked_ips: usize,
    buckets: Mutex<HashMap<IpAddr, (u32, Instant)>>,
}

impl IpRateLimiter {
    #[must_use]
    pub fn new(max_events: u32, window: Duration) -> Self {
        Self {
            max_events: max_events.max(1),
            window,
            max_tracked_ips: max_tracked_ips_from_env(),
            buckets: Mutex::new(HashMap::new()),
        }
    }

    #[must_use]
    pub fn from_env(var: &str, default_max: u32, default_window_secs: u64) -> Self {
        let max = std::env::var(var)
            .ok()
            .and_then(|s| s.parse().ok())
            .unwrap_or(default_max);
        let window_secs = std::env::var(format!("{var}_WINDOW_SECS"))
            .ok()
            .and_then(|s| s.parse().ok())
            .unwrap_or(default_window_secs);
        Self::new(max, Duration::from_secs(window_secs.max(1)))
    }

    /// Returns **`true`** when the event is allowed (and recorded).
    pub fn allow(&self, ip: IpAddr) -> bool {
        let mut guard = self.buckets.lock().expect("rate limit lock poisoned");
        let now = Instant::now();
        self.evict_stale(&mut guard, now);
        let entry = guard.entry(ip).or_insert((0, now));
        if now.duration_since(entry.1) >= self.window {
            *entry = (0, now);
        }
        if entry.0 >= self.max_events {
            return false;
        }
        entry.0 += 1;
        true
    }

    /// Returns **`true`** when **`ip`** is currently over the limit (does not record).
    #[must_use]
    pub fn is_blocked(&self, ip: IpAddr) -> bool {
        let mut guard = self.buckets.lock().expect("rate limit lock poisoned");
        let now = Instant::now();
        self.evict_stale(&mut guard, now);
        let Some((count, start)) = guard.get(&ip) else {
            return false;
        };
        if now.duration_since(*start) >= self.window {
            return false;
        }
        *count >= self.max_events
    }

    fn evict_stale(&self, guard: &mut HashMap<IpAddr, (u32, Instant)>, now: Instant) {
        guard.retain(|_, (_, start)| now.duration_since(*start) < self.window);
        if guard.len() <= self.max_tracked_ips {
            return;
        }
        let mut entries: Vec<_> = guard.iter().map(|(ip, (_, start))| (*ip, *start)).collect();
        entries.sort_by_key(|(_, start)| *start);
        let remove = guard.len().saturating_sub(self.max_tracked_ips);
        for (ip, _) in entries.into_iter().take(remove) {
            guard.remove(&ip);
        }
    }
}

fn max_tracked_ips_from_env() -> usize {
    std::env::var("NAUTALID_RATE_LIMIT_MAX_IPS")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(10_000)
        .max(256)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::{IpAddr, Ipv4Addr};

    #[test]
    fn blocks_after_max_events() {
        let lim = IpRateLimiter::new(2, Duration::from_secs(60));
        let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
        assert!(lim.allow(ip));
        assert!(lim.allow(ip));
        assert!(!lim.allow(ip));
        assert!(lim.is_blocked(ip));
    }

    #[test]
    fn distinct_ips_independent() {
        let lim = IpRateLimiter::new(1, Duration::from_secs(60));
        let a = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
        let b = IpAddr::V4(Ipv4Addr::new(5, 6, 7, 8));
        assert!(lim.allow(a));
        assert!(!lim.allow(a));
        assert!(lim.allow(b));
    }

    #[test]
    fn evicts_stale_ips_before_tracking_new() {
        let lim = IpRateLimiter::new(5, Duration::from_millis(1));
        let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
        assert!(lim.allow(ip));
        std::thread::sleep(Duration::from_millis(2));
        assert!(lim.allow(ip));
    }
}