Skip to main content

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(&params))
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}