Skip to main content

clawft_kernel/
rate_limit.rs

1//! Keyed sliding-window rate limiter (WEFT-148).
2//!
3//! Used for DoS protection on:
4//! - [`crate::cluster::ClusterMembership::add_peer`] — per source
5//! - [`crate::governance::GovernanceEngine::evaluate`] — per principal
6//!
7//! Thresholds are fully configurable via [`RateLimitConfig`]. A limit of
8//! `0` disables enforcement (always allow).
9
10use std::collections::HashMap;
11use std::sync::Mutex;
12use std::time::{Duration, Instant};
13
14/// Configuration for a keyed rate limiter.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct RateLimitConfig {
17    /// Maximum events allowed per key within [`Self::window`].
18    ///
19    /// `0` means unlimited (rate limiting disabled).
20    pub max_per_window: u32,
21    /// Sliding window length.
22    pub window: Duration,
23}
24
25impl RateLimitConfig {
26    /// Disabled rate limiting (always allow).
27    pub const fn unlimited() -> Self {
28        Self {
29            max_per_window: 0,
30            window: Duration::from_secs(0),
31        }
32    }
33
34    /// `max` events per key per minute.
35    pub const fn per_minute(max: u32) -> Self {
36        Self {
37            max_per_window: max,
38            window: Duration::from_secs(60),
39        }
40    }
41
42    /// `max` events per key per second.
43    pub const fn per_second(max: u32) -> Self {
44        Self {
45            max_per_window: max,
46            window: Duration::from_secs(1),
47        }
48    }
49
50    /// Whether this config enforces a limit.
51    pub const fn is_enabled(&self) -> bool {
52        self.max_per_window > 0
53    }
54}
55
56/// Default peer-add limit: 1 addition per source per 100 ms
57/// (preserves the pre-WEFT-148 aggressiveness, but keyed per source).
58pub const DEFAULT_PEER_ADD_RATE: RateLimitConfig = RateLimitConfig {
59    max_per_window: 1,
60    window: Duration::from_millis(100),
61};
62
63/// Default governance-evaluation limit: 256 evaluations per principal
64/// per second. High enough for normal agent tool loops; low enough to
65/// stop a flood from a single principal.
66pub const DEFAULT_GOVERNANCE_EVAL_RATE: RateLimitConfig = RateLimitConfig {
67    max_per_window: 256,
68    window: Duration::from_secs(1),
69};
70
71/// Thread-safe sliding-window rate limiter keyed by an arbitrary string
72/// (source address, principal / agent id, etc.).
73pub struct KeyedRateLimiter {
74    config: Mutex<RateLimitConfig>,
75    /// Per-key timestamps of recent allowed events (oldest first).
76    windows: Mutex<HashMap<String, Vec<Instant>>>,
77}
78
79impl KeyedRateLimiter {
80    /// Create a limiter with the given config.
81    pub fn new(config: RateLimitConfig) -> Self {
82        Self {
83            config: Mutex::new(config),
84            windows: Mutex::new(HashMap::new()),
85        }
86    }
87
88    /// Create an unlimited (always-allow) limiter.
89    pub fn unlimited() -> Self {
90        Self::new(RateLimitConfig::unlimited())
91    }
92
93    /// Current configuration snapshot.
94    pub fn config(&self) -> RateLimitConfig {
95        *self
96            .config
97            .lock()
98            .unwrap_or_else(|e| e.into_inner())
99    }
100
101    /// Replace the configuration at runtime.
102    ///
103    /// Does not clear existing windows; the next `check` uses the new
104    /// thresholds against remaining timestamps.
105    pub fn set_config(&self, config: RateLimitConfig) {
106        *self
107            .config
108            .lock()
109            .unwrap_or_else(|e| e.into_inner()) = config;
110    }
111
112    /// Check whether an event for `key` is allowed.
113    ///
114    /// On allow, records the event. On deny, does not record (so a
115    /// denied flood does not permanently fill the window).
116    ///
117    /// Returns `true` if allowed, `false` if rate-limited.
118    pub fn check(&self, key: &str) -> bool {
119        let config = self.config();
120        if !config.is_enabled() {
121            return true;
122        }
123
124        let now = Instant::now();
125        let mut map = match self.windows.lock() {
126            Ok(g) => g,
127            // Fail open on poisoned lock — DoS protection must not become
128            // a hard-fault denial of service itself.
129            Err(_) => return true,
130        };
131
132        let entry = map.entry(key.to_owned()).or_default();
133        entry.retain(|ts| now.duration_since(*ts) < config.window);
134
135        if entry.len() >= config.max_per_window as usize {
136            return false;
137        }
138
139        entry.push(now);
140        true
141    }
142
143    /// Number of tracked keys (for tests / metrics).
144    pub fn tracked_keys(&self) -> usize {
145        self.windows
146            .lock()
147            .map(|m| m.len())
148            .unwrap_or(0)
149    }
150
151    /// Clear all recorded windows (tests / admin reset).
152    pub fn reset(&self) {
153        if let Ok(mut m) = self.windows.lock() {
154            m.clear();
155        }
156    }
157}
158
159impl Default for KeyedRateLimiter {
160    fn default() -> Self {
161        Self::new(RateLimitConfig::per_minute(60))
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn unlimited_always_allows() {
171        let lim = KeyedRateLimiter::unlimited();
172        for _ in 0..100 {
173            assert!(lim.check("any"));
174        }
175    }
176
177    #[test]
178    fn blocks_after_quota() {
179        let lim = KeyedRateLimiter::new(RateLimitConfig {
180            max_per_window: 3,
181            window: Duration::from_secs(60),
182        });
183        assert!(lim.check("a"));
184        assert!(lim.check("a"));
185        assert!(lim.check("a"));
186        assert!(!lim.check("a"));
187    }
188
189    #[test]
190    fn separate_keys_independent() {
191        let lim = KeyedRateLimiter::new(RateLimitConfig {
192            max_per_window: 1,
193            window: Duration::from_secs(60),
194        });
195        assert!(lim.check("source-1"));
196        assert!(!lim.check("source-1"));
197        assert!(lim.check("source-2"));
198    }
199
200    #[test]
201    fn set_config_disables() {
202        let lim = KeyedRateLimiter::new(RateLimitConfig {
203            max_per_window: 1,
204            window: Duration::from_secs(60),
205        });
206        assert!(lim.check("x"));
207        assert!(!lim.check("x"));
208        lim.set_config(RateLimitConfig::unlimited());
209        assert!(lim.check("x"));
210    }
211
212    #[test]
213    fn default_constants_enabled() {
214        assert!(DEFAULT_PEER_ADD_RATE.is_enabled());
215        assert!(DEFAULT_GOVERNANCE_EVAL_RATE.is_enabled());
216        assert_eq!(DEFAULT_PEER_ADD_RATE.max_per_window, 1);
217        assert_eq!(DEFAULT_GOVERNANCE_EVAL_RATE.max_per_window, 256);
218    }
219}