waf_state_redis/lib.rs
1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Distributed state for [Light WAF](https://github.com/0x00spor3/Light-WAF) over Redis.
5//!
6//! One [`RedisStore`] (one connection pool) provides:
7//!
8//! - **cluster-wide rate limiting** — an implementation of the core's `waf_core::StateStore`
9//! trait, injected with `Proxy::builder(&cfg).state_store(..)` without forking the core;
10//! - **IP reputation** — the [`ReputationStore`] (read) and [`ReputationWriter`] (write) traits,
11//! used by `waf-threatintel`;
12//! - **a shared certificate store** — [`SharedCertStore`] + [`DistributedLock`], used by
13//! `waf-tls-managed` so one node issues an ACME certificate and every node serves it (the private
14//! key encrypted at rest with [`CertCipher`], AES-256-GCM).
15//!
16//! Behavior:
17//!
18//! - **Atomic** refill-then-consume in a single server-side Lua script, so two nodes can never both
19//! allow from the same bucket. Time comes from Redis `TIME`; memory is bounded by per-key TTL.
20//! - **Fail-open at runtime** by default ([`RedisStoreOptions::fail_open`]): a down rate limiter must
21//! not block legitimate traffic. An unreachable Redis at boot is always an error
22//! ([`RedisStore::connect`] pings), so the store never silently degrades to single-node.
23//! - **NOSCRIPT recovery** (`EVALSHA` falls back to `EVAL` when the script cache was flushed).
24//! - **No worker starvation**: the blocking pooled call runs inside `block_in_place` on a
25//! multi-threaded runtime.
26
27use std::fmt;
28use std::net::IpAddr;
29use std::time::Duration;
30
31use r2d2::Pool;
32use redis::{Client, Script};
33use tracing::warn;
34use waf_core::{Acquired, BucketParams, StateStore};
35
36mod certstore;
37pub use certstore::{CertCipher, DecryptError, DistributedLock, SharedCert, SharedCertStore};
38
39/// Token-bucket step, executed atomically server-side.
40///
41/// `KEYS[1]` = bucket key. `ARGV` = capacity, refill/s, cost, ttl(s), now_override
42/// (seconds; `0` ⇒ use Redis `TIME`, any positive value is a deterministic
43/// test clock). Returns `{allowed(0|1), tokens_remaining(string)}` — tokens is
44/// returned as a string so the fractional level survives Redis' Lua→int coercion.
45const BUCKET_LUA: &str = r#"
46local capacity = tonumber(ARGV[1])
47local refill = tonumber(ARGV[2])
48local cost = tonumber(ARGV[3])
49local ttl = tonumber(ARGV[4])
50local now_override = tonumber(ARGV[5])
51
52local now
53if now_override > 0 then
54 now = now_override
55else
56 local t = redis.call('TIME')
57 now = tonumber(t[1]) + (tonumber(t[2]) / 1000000.0)
58end
59
60local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
61local tokens = tonumber(data[1])
62local ts = tonumber(data[2])
63if tokens == nil or ts == nil then
64 tokens = capacity
65 ts = now
66end
67
68local elapsed = now - ts
69if elapsed < 0 then elapsed = 0 end
70tokens = math.min(capacity, tokens + (elapsed * refill))
71
72local allowed = 0
73if tokens >= cost then
74 tokens = tokens - cost
75 allowed = 1
76end
77
78redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
79if ttl > 0 then
80 redis.call('EXPIRE', KEYS[1], ttl)
81end
82
83return {allowed, tostring(tokens)}
84"#;
85
86/// Default connection pool size. Each in-flight rate-limited request borrows one
87/// connection for the duration of the (blocking) round-trip.
88const DEFAULT_POOL_SIZE: u32 = 16;
89
90/// How long a borrow waits for a connection before giving up — short on purpose:
91/// when Redis is unreachable this bounds the fail-open latency on the datapath.
92const CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
93
94/// Tunables for [`RedisStore`] (wired from `[enterprise]` in the config file).
95/// Defaults: a pool of `DEFAULT_POOL_SIZE` and **fail-open** on runtime Redis
96/// errors (a down limiter must not block legitimate traffic).
97#[derive(Clone, Copy, Debug)]
98pub struct RedisStoreOptions {
99 /// Max pooled connections (one is borrowed per in-flight rate-limited request).
100 pub pool_size: u32,
101 /// Posture when Redis errors at runtime: `true` ⇒ allow (fail-open), `false` ⇒
102 /// deny (fail-closed). Boot-time unreachability is always fatal regardless.
103 pub fail_open: bool,
104}
105
106impl Default for RedisStoreOptions {
107 fn default() -> Self {
108 Self { pool_size: DEFAULT_POOL_SIZE, fail_open: true }
109 }
110}
111
112/// A distributed token-bucket [`StateStore`] over Redis. The same pool also backs
113/// [`ReputationStore`] and (when a [`CertCipher`] is attached) [`SharedCertStore`].
114pub struct RedisStore {
115 pool: Pool<Client>,
116 script: Script,
117 fail_open: bool,
118 /// Present only when the cluster-wide cert store is in use — encrypts the private key at
119 /// rest. `None` ⇒ the `SharedCertStore` methods no-op loudly (rate-limit/reputation unaffected).
120 cert_cipher: Option<CertCipher>,
121}
122
123impl RedisStore {
124 /// Connect with default options and **validate eagerly** (PING). See
125 /// [`Self::connect_with_options`].
126 pub fn connect(url: &str) -> Result<Self, ConnectError> {
127 Self::connect_with_options(url, RedisStoreOptions::default())
128 }
129
130 /// Connect with explicit options and **validate eagerly**: an explicitly-configured
131 /// Redis that cannot be reached is a BOOT error (the operator asked for distributed
132 /// state; silently degrading to in-memory would be "looks-protected-but-isn't").
133 /// Runtime errors after a successful connect follow `options.fail_open`.
134 pub fn connect_with_options(url: &str, options: RedisStoreOptions) -> Result<Self, ConnectError> {
135 let store = Self::connect_lazy_with_options(url, options)?;
136 store.ping().map_err(ConnectError::Ping)?;
137 Ok(store)
138 }
139
140 /// Lazy connect (no eager ping) with default options. Useful when the proxy should
141 /// start even if Redis is momentarily down and rely on the per-request posture;
142 /// also the seam the fail-open/closed tests use against a dead address.
143 pub fn connect_lazy(url: &str) -> Result<Self, ConnectError> {
144 Self::connect_lazy_with_options(url, RedisStoreOptions::default())
145 }
146
147 /// Lazy connect (no eager ping) with explicit options.
148 pub fn connect_lazy_with_options(url: &str, options: RedisStoreOptions) -> Result<Self, ConnectError> {
149 let client = Client::open(url).map_err(ConnectError::Client)?;
150 // `build_unchecked` does not test connections at build time — we control
151 // validation explicitly (eager `ping` in `connect*`, posture at runtime).
152 let pool = Pool::builder()
153 .max_size(options.pool_size.max(1))
154 .connection_timeout(CONNECT_TIMEOUT)
155 .build_unchecked(client);
156 Ok(Self {
157 pool,
158 script: Script::new(BUCKET_LUA),
159 fail_open: options.fail_open,
160 cert_cipher: None,
161 })
162 }
163
164 /// Attach the cert-store cipher, enabling the [`SharedCertStore`] capability on this pool.
165 /// Wired only in cluster mode (`[tls.acme].cluster = true`), from `WAF_CERT_STORE_KEY`.
166 pub fn with_cert_cipher(mut self, cipher: CertCipher) -> Self {
167 self.cert_cipher = Some(cipher);
168 self
169 }
170
171 fn ping(&self) -> redis::RedisResult<()> {
172 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
173 redis::cmd("PING").query(&mut *conn)
174 }
175
176 /// Bucket TTL: long enough for an idle bucket to fully refill, then it is safe to
177 /// drop (a fresh key starts at capacity — indistinguishable). No refill ⇒ a fixed
178 /// fallback so keys are never immortal.
179 fn ttl_secs(params: &BucketParams) -> u64 {
180 if params.refill_per_sec > 0.0 {
181 (params.capacity / params.refill_per_sec).ceil() as u64 + 1
182 } else {
183 3600
184 }
185 }
186
187 /// Run the atomic script. `now_override` is `0.0` in production (server `TIME`),
188 /// positive only from deterministic tests.
189 fn run(
190 &self,
191 key: &str,
192 cost: f64,
193 params: BucketParams,
194 now_override: f64,
195 ) -> redis::RedisResult<(i64, String)> {
196 let exec = || {
197 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
198 self.script
199 .key(key)
200 .arg(params.capacity)
201 .arg(params.refill_per_sec)
202 .arg(cost)
203 .arg(Self::ttl_secs(¶ms))
204 .arg(now_override)
205 .invoke::<(i64, String)>(&mut *conn)
206 };
207 // The seam is sync but the datapath is async: yield the worker to other tasks
208 // for the duration of the blocking round-trip when a multi-thread runtime is
209 // present. Outside a runtime (unit tests) just block directly.
210 match tokio::runtime::Handle::try_current() {
211 Ok(_) => tokio::task::block_in_place(exec),
212 Err(_) => exec(),
213 }
214 }
215
216 fn acquire(&self, key: &str, cost: f64, params: BucketParams, now_override: f64) -> Acquired {
217 match self.run(key, cost, params, now_override) {
218 Ok((allowed, remaining)) => Acquired {
219 allowed: allowed == 1,
220 tokens_remaining: remaining.parse().unwrap_or(0.0),
221 },
222 // Posture per `fail_open`. Loud either way so the operator sees the
223 // degraded state. Default fail-open: a down limiter must not take the
224 // site down (the opposite of the security modules' fail-closed —
225 // correct because state is infrastructure, not detection).
226 Err(e) if self.fail_open => {
227 warn!(error = %e, key = %key, "redis StateStore error — FAILING OPEN (request allowed)");
228 Acquired { allowed: true, tokens_remaining: params.capacity }
229 }
230 Err(e) => {
231 warn!(error = %e, key = %key, "redis StateStore error — FAILING CLOSED (request denied)");
232 Acquired { allowed: false, tokens_remaining: 0.0 }
233 }
234 }
235 }
236
237 /// Test seam: identical to [`StateStore::try_acquire`] but with an explicit `now`
238 /// (seconds) so the token-bucket arithmetic is deterministic against the in-memory
239 /// oracle. Production always uses `try_acquire` (server `TIME`).
240 pub fn try_acquire_at(&self, key: &str, cost: f64, params: BucketParams, now_secs: f64) -> Acquired {
241 self.acquire(key, cost, params, now_secs)
242 }
243}
244
245impl StateStore for RedisStore {
246 fn try_acquire(&self, key: &str, cost: f64, params: BucketParams) -> Acquired {
247 self.acquire(key, cost, params, 0.0)
248 }
249}
250
251// ── IP reputation (ENTERPRISE-only capability; NOT on the core `StateStore`) ───
252//
253// Cluster-wide IP reputation read from the SAME Redis (a keyspace separate from the rate-limit
254// buckets, populated by the threat-intel feed). It is a plain READ — no atomicity / Lua, unlike
255// the token bucket. A premium `WafModule` (`waf-threatintel`) consumes it. Deliberately an
256// enterprise trait, NOT a method on `waf_core::StateStore`: the OPEN datapath never calls
257// reputation, so it must not enter the frozen core ABI.
258
259/// Reputation verdict for an IP. `Unknown` = no opinion (also the fail-safe on any error).
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum Reputation {
262 Unknown,
263 Suspicious,
264 Malicious,
265}
266
267impl Reputation {
268 /// The wire value stored in / read from Redis. `Unknown` has **no** wire form (an absent key
269 /// already means Unknown), so the feed never persists it. This is the SINGLE encoding point
270 /// shared by the read ([`ReputationStore::reputation`]) and the write
271 /// ([`ReputationWriter::apply_snapshot`]) — they cannot drift (the load-bearing "key+value in
272 /// one place" contract: a mismatch would silently populate keys the reader never hits).
273 fn as_wire(self) -> Option<&'static str> {
274 match self {
275 Self::Malicious => Some("malicious"),
276 Self::Suspicious => Some("suspicious"),
277 Self::Unknown => None,
278 }
279 }
280
281 /// Inverse of [`Self::as_wire`]: any unrecognized / absent value collapses to `Unknown`.
282 fn from_wire(s: &str) -> Self {
283 match s {
284 "malicious" => Self::Malicious,
285 "suspicious" => Self::Suspicious,
286 _ => Self::Unknown,
287 }
288 }
289
290 /// Ordering by severity for the feed's in-memory dedup (an IP present in both a "malicious"
291 /// and a "suspicious" source keeps the stronger verdict). `Unknown` is the weakest.
292 fn rank(self) -> u8 {
293 match self {
294 Self::Unknown => 0,
295 Self::Suspicious => 1,
296 Self::Malicious => 2,
297 }
298 }
299}
300
301/// Read-only cluster-wide IP-reputation lookup — the enterprise injection seam a premium
302/// reputation module consumes (the analogue of `StateStore`, but enterprise-side).
303pub trait ReputationStore: Send + Sync {
304 /// Reputation of `ip`. Takes [`IpAddr`] (not `&str`) so the canonical key form is fixed in
305 /// exactly ONE place; any error or absence yields [`Reputation::Unknown`].
306 fn reputation(&self, ip: IpAddr) -> Reputation;
307}
308
309/// Write side of cluster-wide IP-reputation — the seam the threat-intel **feed** (§6) consumes to
310/// populate the same `rep:<ip>` keyspace the [`ReputationStore`] reads. Deliberately a separate
311/// trait from the read: the datapath never writes, and keeping it here (next to `rep_key` and
312/// `Reputation::as_wire`) means the key + value encoding live in ONE crate, shared by both sides.
313pub trait ReputationWriter: Send + Sync {
314 /// Publish a snapshot of `(ip, verdict)` entries, each `SET rep:<ip> <verdict> EX <ttl>`.
315 /// `Unknown` entries are skipped (no key written). Returns the number of keys written.
316 ///
317 /// **TTL-based snapshot, no atomic swap**: a de-listed IP simply ages out once it stops being
318 /// refreshed, and a down feed lets every entry expire back to `Unknown` — the same
319 /// fail-to-no-opinion posture as the read. Callers pick `ttl` a few refresh cycles wide so a
320 /// single missed cycle never drops still-listed IPs.
321 fn apply_snapshot(&self, entries: &[(IpAddr, Reputation)], ttl: Duration) -> usize;
322}
323
324/// The Redis key for an IP's reputation. `IpAddr`'s `Display` is the RFC 5952 canonical form
325/// (compressed IPv6, lowercase, no brackets / zone-id), so this is the single normalization
326/// point shared by lookups and any writer (the feed / a seed tool / `redis-cli`). Format:
327/// `rep:<ip>` → value `"malicious"` | `"suspicious"` (absent ⇒ Unknown).
328fn rep_key(ip: IpAddr) -> String {
329 format!("rep:{ip}")
330}
331
332impl ReputationStore for RedisStore {
333 fn reputation(&self, ip: IpAddr) -> Reputation {
334 let key = rep_key(ip);
335 let exec = || -> redis::RedisResult<Option<String>> {
336 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
337 redis::cmd("GET").arg(&key).query::<Option<String>>(&mut *conn)
338 };
339 let result = match tokio::runtime::Handle::try_current() {
340 Ok(_) => tokio::task::block_in_place(exec),
341 Err(_) => exec(),
342 };
343 match result {
344 // Same encoding as the writer, via the shared `from_wire` (contract: one place).
345 Ok(Some(v)) => Reputation::from_wire(&v),
346 Ok(None) => Reputation::Unknown,
347 // fail-to-no-opinion: a down feed must neither block nor unblock — the pipeline
348 // still inspects. A SECURITY property (see the bite-test), not an edge case.
349 Err(e) => {
350 warn!(error = %e, ip = %ip, "redis reputation lookup error — treating as Unknown");
351 Reputation::Unknown
352 }
353 }
354 }
355}
356
357/// Resolve a raw list of `(ip, verdict)` (possibly with duplicate IPs from overlapping feed
358/// sources) into the concrete `SET rep:<ip> <value>` write-set: dedup per IP keeping the STRONGEST
359/// verdict, drop `Unknown` (no wire form), and map to keys via the shared [`rep_key`]. Pure and
360/// Redis-free so the dedup/encoding contract is unit-tested offline; [`RedisStore::apply_snapshot`]
361/// just pipelines the result.
362fn resolve_writes(entries: &[(IpAddr, Reputation)]) -> Vec<(String, &'static str)> {
363 use std::collections::HashMap;
364 let mut strongest: HashMap<IpAddr, Reputation> = HashMap::new();
365 for (ip, rep) in entries {
366 let slot = strongest.entry(*ip).or_insert(Reputation::Unknown);
367 if rep.rank() > slot.rank() {
368 *slot = *rep;
369 }
370 }
371 strongest
372 .into_iter()
373 .filter_map(|(ip, rep)| rep.as_wire().map(|v| (rep_key(ip), v)))
374 .collect()
375}
376
377impl ReputationWriter for RedisStore {
378 fn apply_snapshot(&self, entries: &[(IpAddr, Reputation)], ttl: Duration) -> usize {
379 let writes = resolve_writes(entries);
380 if writes.is_empty() {
381 return 0;
382 }
383 // EX needs a whole-second TTL ≥ 1; the caller already sizes it in seconds.
384 let ttl_secs = ttl.as_secs().max(1);
385 let exec = || -> redis::RedisResult<()> {
386 let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
387 // One pipelined round-trip for the whole snapshot (a feed list is large; a SET per
388 // round-trip would be pathological). Not atomic across keys — deliberate (F2).
389 let mut pipe = redis::pipe();
390 for (k, v) in &writes {
391 pipe.cmd("SET").arg(k).arg(*v).arg("EX").arg(ttl_secs).ignore();
392 }
393 pipe.query::<()>(&mut *conn)
394 };
395 let result = match tokio::runtime::Handle::try_current() {
396 Ok(_) => tokio::task::block_in_place(exec),
397 Err(_) => exec(),
398 };
399 match result {
400 Ok(()) => writes.len(),
401 // Fail-safe on the write side too: a failed cycle simply does not refresh — existing
402 // entries keep living until their TTL, then age out to `Unknown`. Never fabricate.
403 Err(e) => {
404 warn!(error = %e, "redis reputation feed write error — snapshot not applied this cycle");
405 0
406 }
407 }
408 }
409}
410
411/// Map an r2d2 pool error (exhausted / unreachable) into a redis error so the
412/// fail-open path in [`RedisStore::acquire`] handles it uniformly.
413fn pool_to_redis_err(e: r2d2::Error) -> redis::RedisError {
414 redis::RedisError::from((
415 redis::ErrorKind::IoError,
416 "redis pool unavailable",
417 e.to_string(),
418 ))
419}
420
421/// Why constructing a [`RedisStore`] failed (boot-time only; runtime errors fail-open).
422#[derive(Debug)]
423pub enum ConnectError {
424 /// Malformed connection URL.
425 Client(redis::RedisError),
426 /// URL parsed but Redis did not answer the eager `PING`.
427 Ping(redis::RedisError),
428}
429
430impl fmt::Display for ConnectError {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 match self {
433 Self::Client(e) => write!(f, "invalid Redis URL: {e}"),
434 Self::Ping(e) => write!(f, "cannot reach Redis (PING failed): {e}"),
435 }
436 }
437}
438
439impl std::error::Error for ConnectError {}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use waf_core::StateStore;
445
446 #[test]
447 fn fails_open_when_redis_unreachable() {
448 // Nothing listens on this port → every try_acquire must ALLOW (fail-open).
449 // Lazy connect (no eager ping) so construction itself does not error here.
450 let store = RedisStore::connect_lazy("redis://127.0.0.1:1").expect("url parses");
451 let params = BucketParams { capacity: 3.0, refill_per_sec: 1.0 };
452 for _ in 0..5 {
453 let a = store.try_acquire("any-key", 1.0, params);
454 assert!(a.allowed, "rate-limit store MUST fail open when Redis is down");
455 }
456 }
457
458 #[test]
459 fn fails_closed_when_configured() {
460 // Same dead port, but fail_open=false → every try_acquire must DENY.
461 let opts = RedisStoreOptions { fail_open: false, ..Default::default() };
462 let store = RedisStore::connect_lazy_with_options("redis://127.0.0.1:1", opts)
463 .expect("url parses");
464 let params = BucketParams { capacity: 3.0, refill_per_sec: 1.0 };
465 let a = store.try_acquire("any-key", 1.0, params);
466 assert!(!a.allowed, "fail_open=false must DENY when Redis is down");
467 }
468
469 #[test]
470 fn rep_key_is_canonical_for_v4_and_v6() {
471 assert_eq!(rep_key("1.2.3.4".parse().unwrap()), "rep:1.2.3.4");
472 // RFC 5952 canonical: compressed, lowercase, no leading zeros.
473 let v6: std::net::IpAddr = "2001:0DB8:0000:0000:0000:0000:0000:0001".parse().unwrap();
474 assert_eq!(rep_key(v6), "rep:2001:db8::1");
475 }
476
477 #[test]
478 fn reputation_is_unknown_when_redis_unreachable() {
479 // Down feed ⇒ no opinion (fail-to-Unknown), so the pipeline still inspects.
480 let store = RedisStore::connect_lazy("redis://127.0.0.1:1").expect("url parses");
481 let ip: std::net::IpAddr = "9.9.9.9".parse().unwrap();
482 assert_eq!(store.reputation(ip), Reputation::Unknown);
483 }
484
485 #[test]
486 fn wire_encoding_round_trips_and_is_the_single_source_of_truth() {
487 // read (`from_wire`) and write (`as_wire`) MUST agree — the F1 contract in one place.
488 for r in [Reputation::Malicious, Reputation::Suspicious] {
489 let w = r.as_wire().expect("has a wire form");
490 assert_eq!(Reputation::from_wire(w), r, "round-trip {r:?}");
491 }
492 // Unknown has no wire form (absent key ⇒ Unknown), and junk decodes to Unknown.
493 assert_eq!(Reputation::Unknown.as_wire(), None);
494 assert_eq!(Reputation::from_wire(""), Reputation::Unknown);
495 assert_eq!(Reputation::from_wire("MALICIOUS"), Reputation::Unknown, "case-sensitive on purpose");
496 assert_eq!(Reputation::from_wire("bogus"), Reputation::Unknown);
497 }
498
499 #[test]
500 fn resolve_writes_dedups_to_strongest_and_drops_unknown() {
501 let a: IpAddr = "1.1.1.1".parse().unwrap();
502 let b: IpAddr = "2.2.2.2".parse().unwrap();
503 let c: IpAddr = "3.3.3.3".parse().unwrap();
504 // `a` appears in both a suspicious and a malicious source (either order) ⇒ malicious wins.
505 // `c` is Unknown ⇒ no write. `b` stays suspicious.
506 let entries = [
507 (a, Reputation::Suspicious),
508 (b, Reputation::Suspicious),
509 (a, Reputation::Malicious),
510 (c, Reputation::Unknown),
511 ];
512 let mut writes = resolve_writes(&entries);
513 writes.sort();
514 assert_eq!(
515 writes,
516 vec![
517 ("rep:1.1.1.1".to_string(), "malicious"),
518 ("rep:2.2.2.2".to_string(), "suspicious"),
519 ],
520 "strongest verdict per IP, Unknown dropped, keyed via rep_key"
521 );
522 }
523
524 #[test]
525 fn apply_snapshot_with_only_unknown_writes_nothing_without_touching_redis() {
526 // All-Unknown ⇒ empty write-set ⇒ early return, so a dead Redis is never contacted.
527 let store = RedisStore::connect_lazy("redis://127.0.0.1:1").expect("url parses");
528 let ip: IpAddr = "9.9.9.9".parse().unwrap();
529 let n = store.apply_snapshot(&[(ip, Reputation::Unknown)], Duration::from_secs(60));
530 assert_eq!(n, 0, "nothing to write, and no Redis round-trip attempted");
531 }
532
533 #[test]
534 fn apply_snapshot_returns_zero_when_redis_unreachable() {
535 // Fail-safe write side: a down Redis means the cycle simply does not refresh (returns 0),
536 // never panics. Existing entries age out via TTL to Unknown.
537 let store = RedisStore::connect_lazy("redis://127.0.0.1:1").expect("url parses");
538 let ip: IpAddr = "203.0.113.1".parse().unwrap();
539 let n = store.apply_snapshot(&[(ip, Reputation::Malicious)], Duration::from_secs(60));
540 assert_eq!(n, 0, "write error ⇒ 0 applied, no panic");
541 }
542
543 #[test]
544 fn ttl_covers_full_refill() {
545 let p = BucketParams { capacity: 100.0, refill_per_sec: 10.0 };
546 assert_eq!(RedisStore::ttl_secs(&p), 11); // ceil(100/10)+1
547 let none = BucketParams { capacity: 5.0, refill_per_sec: 0.0 };
548 assert_eq!(RedisStore::ttl_secs(&none), 3600);
549 }
550}