1use std::net::IpAddr;
52
53#[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
66pub trait SlidingWindowStore: Send + Sync {
68 fn record_hit(&self, key: &str, now: f64, window: u64) -> Result<u64, StoreError>;
76}
77
78pub trait BanStore: Send + Sync {
81 fn set_ban(&self, key: &str, expiry: f64, ttl_seconds: u64) -> Result<(), StoreError>;
88
89 fn get_ban(&self, key: &str) -> Result<Option<f64>, StoreError>;
95
96 fn delete_ban(&self, key: &str) -> Result<(), StoreError>;
102}
103
104#[must_use]
107pub fn rate_window_key(prefix: &str, ip: IpAddr) -> String {
108 format!("{prefix}rate_limit:rate:{ip}")
109}
110
111#[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#[must_use]
124pub fn ban_key(prefix: &str, ip: IpAddr) -> String {
125 format!("{prefix}banned_ips:{ip}")
126}
127
128#[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 assert_eq!(
175 identity_hash(""),
176 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
177 );
178 assert_eq!(
180 identity_hash("/login"),
181 "7e93fba0bc7adda858a2500090e639e4f563a3965c7cf4c719fb56eb6b12c666"
182 );
183 }
184}
185
186#[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 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 let blocked = limiter.check_distributed(visitor, None).expect("ok");
298 assert!(!blocked.allowed);
299 assert_eq!(blocked.count, 4);
300 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 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 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 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 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 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}