Skip to main content

guard_core_engine/
distributed.rs

1//! The distributed-store seam: the engine-side traits and key helpers
2//! for Redis-backed (or any backend) rate limiting and bans.
3//!
4//! Sources: `guard_core/handlers/ratelimit_handler.py`, `_ipban_bans.py`,
5//! `_ipban_queries.py`, `redis_handler.py`. The traits are synchronous
6//! and assumption-ready, the same pattern the geo handler seam uses.
7//!
8//! The engine stays I/O-free: the traits are synchronous and
9//! assumption-ready (the same pattern the [`crate::geo::GeoIpHandler`]
10//! seam uses), and backends live in adapters - the facade crate ships a
11//! Redis implementation behind its `redis` feature.
12//!
13//! ## The reference window semantics, point by point
14//!
15//! One recorded hit runs the reference's four operations in one
16//! transaction over the key `{prefix}rate_limit:rate:{ip}` (global
17//! scope) or `{prefix}rate_limit:rate:{ip}:{sha256(endpoint)}` (endpoint
18//! scope, `_hash_identity_segment`):
19//!
20//! 1. `ZADD key now now`
21//! 2. `ZREMRANGEBYSCORE key 0 (now - window)` (the reference bounds are
22//!    inclusive, exactly its Lua/pipeline arguments)
23//! 3. `ZCARD key`
24//! 4. `EXPIRE key window * 2`
25//!
26//! The Redis store compares the post-recording count against the limit
27//! (`allowed = count <= limit`); the in-memory store compares the
28//! pre-recording count (`count < limit`) - the same boundary, as the
29//! reference documents on its own module docstring.
30//!
31//! ## Ban storage
32//!
33//! A ban writes `SET {prefix}banned_ips:{ip} <expiry unix seconds> EX
34//! ttl` (the reference `set_key("banned_ips", ip, str(expiry),
35//! ttl=duration)`); a lookup reads the key, honors `now <= expiry`,
36//! and deletes the stale key otherwise (the reference
37//! `_check_redis_exact`). A failed ban write degrades to the local
38//! store with the TTL clamped to the local cap, exactly the reference
39//! `except` branch.
40//!
41//! ## Failure semantics
42//!
43//! The reference `redis_fail_open` flag (`false` by default): a failing
44//! Redis call raises `GuardRedisError(503, "Redis rate limiting
45//! unavailable")` when closed, and falls back to the in-memory window
46//! (with a one-time warning) when open. The port mirrors both: a
47//! [`StoreError`] from the window store surfaces as `Err` (the facade
48//! stage answers the 503) or degrades to the in-memory window under
49//! fail-open.
50
51use std::net::IpAddr;
52
53/// A backend failure, wrapped losslessly as a string (the engine never
54/// sees backend types).
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct StoreError(pub String);
57
58impl core::fmt::Display for StoreError {
59    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
60        write!(f, "distributed store error: {}", self.0)
61    }
62}
63
64impl std::error::Error for StoreError {}
65
66/// The reference rate-limit window operations, one hit per call.
67pub trait SlidingWindowStore: Send + Sync {
68    /// Record one hit at `now` into the window `key` and return the
69    /// post-eviction `ZCARD` count. The four reference operations run
70    /// atomically (one transaction).
71    ///
72    /// # Errors
73    ///
74    /// [`StoreError`] when the backend call fails.
75    fn record_hit(&self, key: &str, now: f64, window: u64) -> Result<u64, StoreError>;
76}
77
78/// The reference `banned_ips` storage operations (the
79/// `{prefix}banned_ips:{ip}` namespace).
80pub trait BanStore: Send + Sync {
81    /// `SET key expiry EX ttl` with the expiry as unix seconds (the
82    /// reference `set_key("banned_ips", ip, str(expiry), ttl=duration)`).
83    ///
84    /// # Errors
85    ///
86    /// [`StoreError`] when the backend call fails.
87    fn set_ban(&self, key: &str, expiry: f64, ttl_seconds: u64) -> Result<(), StoreError>;
88
89    /// `GET key`, parsed to the stored expiry unix seconds.
90    ///
91    /// # Errors
92    ///
93    /// [`StoreError`] when the backend call fails.
94    fn get_ban(&self, key: &str) -> Result<Option<f64>, StoreError>;
95
96    /// `DEL key` for a stale ban (the reference `_check_redis_exact`).
97    ///
98    /// # Errors
99    ///
100    /// [`StoreError`] when the backend call fails.
101    fn delete_ban(&self, key: &str) -> Result<(), StoreError>;
102}
103
104/// The `{prefix}rate_limit:rate:{ip}` window key, the global tier's
105/// bucket (`rate_key = f"rate:{client_ip}"`).
106#[must_use]
107pub fn rate_window_key(prefix: &str, ip: IpAddr) -> String {
108    format!("{prefix}rate_limit:rate:{ip}")
109}
110
111/// The `{prefix}rate_limit:rate:{ip}:{endpoint hash}` window key, the
112/// endpoint tier's bucket (`_hash_identity_segment` = sha256 hex).
113#[must_use]
114pub fn rate_window_key_endpoint(prefix: &str, ip: IpAddr, endpoint_path: &str) -> String {
115    format!(
116        "{prefix}rate_limit:rate:{ip}:{}",
117        identity_hash(endpoint_path)
118    )
119}
120
121/// The `{prefix}banned_ips:{ip}` ban key (`set_key("banned_ips", ip,
122/// ...)`'s full key).
123#[must_use]
124pub fn ban_key(prefix: &str, ip: IpAddr) -> String {
125    format!("{prefix}banned_ips:{ip}")
126}
127
128/// `_hash_identity_segment`: the sha256 hex digest of the UTF-8 value.
129#[must_use]
130pub fn identity_hash(value: &str) -> String {
131    use sha2::{Digest, Sha256};
132    let mut hasher = Sha256::new();
133    hasher.update(value.as_bytes());
134    let digest = hasher.finalize();
135    digest
136        .iter()
137        .fold(String::with_capacity(64), |mut out, byte| {
138            use core::fmt::Write as _;
139            let _ = write!(out, "{byte:02x}");
140            out
141        })
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::str::FromStr;
148
149    #[test]
150    fn key_layouts_match_the_reference() {
151        let ip = IpAddr::from_str("192.0.2.1").unwrap();
152        assert_eq!(
153            rate_window_key("guard_core:", ip),
154            "guard_core:rate_limit:rate:192.0.2.1"
155        );
156        assert_eq!(
157            ban_key("guard_core:", ip),
158            "guard_core:banned_ips:192.0.2.1"
159        );
160    }
161
162    #[test]
163    fn endpoint_keys_hash_the_path_segment() {
164        let ip = IpAddr::from_str("192.0.2.1").unwrap();
165        let key = rate_window_key_endpoint("guard_core:", ip, "/login");
166        assert!(key.starts_with("guard_core:rate_limit:rate:192.0.2.1:"));
167        let digest = &key["guard_core:rate_limit:rate:192.0.2.1:".len()..];
168        assert_eq!(digest.len(), 64, "sha256 hex digest");
169    }
170
171    #[test]
172    fn identity_hash_matches_the_reference_vectors() {
173        // `hashlib.sha256(b"").hexdigest()`
174        assert_eq!(
175            identity_hash(""),
176            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
177        );
178        // `hashlib.sha256(b"/login").hexdigest()`
179        assert_eq!(
180            identity_hash("/login"),
181            "7e93fba0bc7adda858a2500090e639e4f563a3965c7cf4c719fb56eb6b12c666"
182        );
183    }
184}
185
186/// An in-memory backend for the distributed paths: the same operations a
187/// Redis runs, keyed the same way, so the engine's store paths are
188/// testable without a backend process.
189#[derive(Default)]
190pub struct MemoryStore {
191    pub windows: std::sync::Mutex<std::collections::HashMap<String, Vec<(f64, f64)>>>,
192    pub bans: std::sync::Mutex<std::collections::HashMap<String, f64>>,
193    /// When set, every operation fails (the fail-closed/fail-open tests).
194    pub fail: std::sync::atomic::AtomicBool,
195}
196
197impl SlidingWindowStore for MemoryStore {
198    fn record_hit(&self, key: &str, now: f64, window: u64) -> Result<u64, StoreError> {
199        if self.fail.load(std::sync::atomic::Ordering::Relaxed) {
200            return Err(StoreError(String::from("backend down")));
201        }
202        let count;
203        {
204            let mut windows = self.windows.lock().expect("windows");
205            let entries = windows.entry(key.to_owned()).or_default();
206            entries.push((now, now));
207            let window_start = now - f64::from(u32::try_from(window).unwrap_or(u32::MAX));
208            entries.retain(|&(score, _)| score > window_start);
209            count = entries.len();
210            drop(windows);
211        }
212        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
213        Ok(count as u64)
214    }
215}
216
217impl BanStore for MemoryStore {
218    fn set_ban(&self, key: &str, expiry: f64, _ttl_seconds: u64) -> Result<(), StoreError> {
219        if self.fail.load(std::sync::atomic::Ordering::Relaxed) {
220            return Err(StoreError(String::from("backend down")));
221        }
222        self.bans
223            .lock()
224            .expect("bans")
225            .insert(key.to_owned(), expiry);
226        Ok(())
227    }
228
229    fn get_ban(&self, key: &str) -> Result<Option<f64>, StoreError> {
230        if self.fail.load(std::sync::atomic::Ordering::Relaxed) {
231            return Err(StoreError(String::from("backend down")));
232        }
233        Ok(self.bans.lock().expect("bans").get(key).copied())
234    }
235
236    fn delete_ban(&self, key: &str) -> Result<(), StoreError> {
237        if self.fail.load(std::sync::atomic::Ordering::Relaxed) {
238            return Err(StoreError(String::from("backend down")));
239        }
240        self.bans.lock().expect("bans").remove(key);
241        Ok(())
242    }
243}
244
245#[cfg(test)]
246mod distributed_tests {
247    use super::*;
248    use std::str::FromStr;
249    use std::sync::Arc;
250
251    use crate::ip_ban::IpBanManager;
252    use crate::rate_limit::{RateLimitConfig, RateLimiter};
253
254    fn ip(text: &str) -> IpAddr {
255        IpAddr::from_str(text).expect("test address")
256    }
257
258    fn store_config(limit: u32) -> RateLimitConfig {
259        RateLimitConfig {
260            enable_rate_limiting: true,
261            rate_limit: limit,
262            ..RateLimitConfig::default()
263        }
264    }
265
266    #[test]
267    fn distributed_windows_share_one_budget_across_limiter_clones() {
268        let store = Arc::new(MemoryStore::default());
269        let limiter = RateLimiter::new(store_config(3))
270            .expect("config")
271            .with_distributed_store(
272                Arc::clone(&store) as Arc<dyn SlidingWindowStore>,
273                "guard_core:",
274                false,
275            );
276        let visitor = ip("192.0.2.81");
277        assert!(
278            limiter
279                .check_distributed(visitor, None)
280                .expect("ok")
281                .allowed
282        );
283        assert!(
284            limiter
285                .check_distributed(visitor, None)
286                .expect("ok")
287                .allowed
288        );
289        assert!(
290            limiter
291                .check_distributed(visitor, None)
292                .expect("ok")
293                .allowed
294        );
295        // The post-recording formulation: the 4th hit records (count 4)
296        // and decides count <= limit.
297        let blocked = limiter.check_distributed(visitor, None).expect("ok");
298        assert!(!blocked.allowed);
299        assert_eq!(blocked.count, 4);
300        // The shared store is the Redis key, visible to a second limiter.
301        assert!(
302            store
303                .windows
304                .lock()
305                .expect("windows")
306                .contains_key("guard_core:rate_limit:rate:192.0.2.81")
307        );
308    }
309
310    #[test]
311    fn fail_closed_surfaces_the_backend_error() {
312        let store = Arc::new(MemoryStore::default());
313        store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
314        let limiter = RateLimiter::new(store_config(3))
315            .expect("config")
316            .with_distributed_store(
317                Arc::clone(&store) as Arc<dyn SlidingWindowStore>,
318                "guard_core:",
319                false,
320            );
321        let error = limiter
322            .check_distributed(ip("192.0.2.82"), None)
323            .expect_err("fail closed");
324        assert!(error.to_string().contains("backend down"));
325    }
326
327    #[test]
328    fn fail_open_degrades_to_the_in_memory_window() {
329        let store = Arc::new(MemoryStore::default());
330        store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
331        let limiter = RateLimiter::new(store_config(1))
332            .expect("config")
333            .with_distributed_store(
334                Arc::clone(&store) as Arc<dyn SlidingWindowStore>,
335                "guard_core:",
336                true,
337            );
338        let visitor = ip("192.0.2.83");
339        assert!(
340            limiter
341                .check_distributed(visitor, None)
342                .expect("ok")
343                .allowed
344        );
345        let blocked = limiter.check_distributed(visitor, None).expect("ok");
346        assert!(!blocked.allowed);
347        // Nothing reached the failing backend; the in-memory window ran.
348        assert!(store.windows.lock().expect("windows").is_empty());
349    }
350
351    #[test]
352    fn distributed_bans_read_through_and_expire() {
353        let store = Arc::new(MemoryStore::default());
354        let bans = IpBanManager::with_clock(Arc::new(|| 1_000.0))
355            .with_distributed_store(Arc::clone(&store) as Arc<dyn BanStore>, "guard_core:");
356        let attacker = ip("192.0.2.84");
357        // A local ban writes through to the store under the reference key.
358        assert!(
359            bans.ban_ip(attacker, 60, "penetration_attempt")
360                .expect("ban")
361        );
362        assert_eq!(
363            store
364                .bans
365                .lock()
366                .expect("bans")
367                .get("guard_core:banned_ips:192.0.2.84"),
368            Some(&1_060.0)
369        );
370        // A fresh manager (a cold local cache, another worker) reads the
371        // distributed ban and caches it.
372        let other_worker = IpBanManager::with_clock(Arc::new(|| 1_010.0))
373            .with_distributed_store(Arc::clone(&store) as Arc<dyn BanStore>, "guard_core:");
374        assert!(other_worker.is_banned(attacker));
375        // Past expiry the same read deletes the stale key.
376        let expired_worker = IpBanManager::with_clock(Arc::new(|| 2_000.0))
377            .with_distributed_store(Arc::clone(&store) as Arc<dyn BanStore>, "guard_core:");
378        assert!(!expired_worker.is_banned(attacker));
379        assert!(
380            !store
381                .bans
382                .lock()
383                .expect("bans")
384                .contains_key("guard_core:banned_ips:192.0.2.84")
385        );
386    }
387}
388
389#[cfg(test)]
390mod coverage_tests {
391    use super::*;
392
393    #[test]
394    fn a_failing_backend_surfaces_the_store_error() {
395        let store = MemoryStore::default();
396        store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
397        // any backend call answers the backend-down error
398        assert!(store.record_hit("k", 1_000.0, 60).is_err());
399    }
400}
401
402#[cfg(test)]
403mod gap_tests {
404    use super::*;
405
406    #[test]
407    fn a_failing_backend_surfaces_the_store_error_for_ban_operations() {
408        let store = MemoryStore::default();
409        store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
410        assert!(store.set_ban("k", 1_000.0, 60).is_err());
411        assert!(store.get_ban("k").is_err());
412        assert!(store.delete_ban("k").is_err());
413    }
414}