Skip to main content

waf_core/
state.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared state seam (BOUNDARY §1.5/§4). The `StateStore` trait is the public ABI
5//! onto which the enterprise multi-node store (Redis/shared) plugs in without a
6//! fork; the in-memory token-bucket implementation is the OPEN single-node default.
7//! Lives in `waf-core` (not a separate `waf-state` crate) because the consumers
8//! (`waf-detection`'s rate limiter, `waf-proxy`'s wiring) already depend on
9//! `waf-core` — placing it here introduces no dependency cycle.
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15// ── clock (injectable for deterministic tests) ────────────────────────────────
16
17/// Monotonic time source. `Instant` is opaque in Rust, so `ManualClock` starts
18/// from a real `Instant::now()` and advances by adding `Duration` — it never
19/// constructs arbitrary instants.
20pub trait Clock: Send + Sync {
21    fn now(&self) -> Instant;
22}
23
24pub struct SystemClock;
25
26impl Clock for SystemClock {
27    fn now(&self) -> Instant {
28        Instant::now()
29    }
30}
31
32/// Test clock: advances explicitly via `advance`.
33pub struct ManualClock {
34    base: Instant,
35    offset: Mutex<Duration>,
36}
37
38impl ManualClock {
39    pub fn new() -> Self {
40        Self { base: Instant::now(), offset: Mutex::new(Duration::ZERO) }
41    }
42    pub fn advance(&self, by: Duration) {
43        *self.offset.lock().unwrap() += by;
44    }
45}
46
47impl Default for ManualClock {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl Clock for ManualClock {
54    fn now(&self) -> Instant {
55        self.base + *self.offset.lock().unwrap()
56    }
57}
58
59// ── StateStore (the frozen extension seam — BOUNDARY §4) ───────────────────────
60
61/// Parameters of one token bucket, passed **per call** so the store stays free of
62/// config: a hot reload changes capacity/refill on the module while the store keeps
63/// the live buckets, and the same key can be re-parameterised without a store reset.
64#[derive(Clone, Copy)]
65pub struct BucketParams {
66    pub capacity: f64,
67    pub refill_per_sec: f64,
68}
69
70/// Outcome of one `try_acquire`. `tokens_remaining` is the bucket level **after**
71/// the attempt (post-refill, post-consume on success) and feeds `Retry-After`.
72pub struct Acquired {
73    pub allowed: bool,
74    pub tokens_remaining: f64,
75}
76
77/// The extension seam onto which the enterprise multi-node store plugs in
78/// (Redis/shared store — BOUNDARY §2.1/§4). **Public ABI, frozen before the first
79/// release (§5).**
80///
81/// The contract is a single *atomic* operation, not a get/update pair: refilling a
82/// bucket and consuming from it MUST be indivisible w.r.t. concurrent callers,
83/// otherwise two nodes (or threads) read the same level and both allow — a
84/// cluster-wide over-allow (TOCTOU). In-memory enforces this under one lock; a
85/// Redis impl uses a single server-side script. Time and memory-bounding are the
86/// store's concern (in-memory owns a `Clock` and a tracked-key cap; Redis uses its
87/// own server time and TTL), so they never appear in the ABI.
88pub trait StateStore: Send + Sync {
89    /// Atomically refill `key`'s bucket for the elapsed time, then try to take
90    /// `cost` tokens. Allowed iff at least `cost` tokens are available.
91    fn try_acquire(&self, key: &str, cost: f64, params: BucketParams) -> Acquired;
92}
93
94// ── in-memory token-bucket store (the OPEN impl) ───────────────────────────────
95
96#[derive(Clone, Copy)]
97struct Bucket {
98    tokens: f64,
99    last: Instant,
100}
101
102struct InMemState {
103    buckets: HashMap<String, Bucket>,
104}
105
106/// Default tracked-key cap, mirrors `RateLimitConfig`'s default. The cap is a
107/// memory bound on this single-process store; a distributed store bounds memory
108/// with TTL/maxmemory instead, which is why the cap is not part of the ABI.
109const DEFAULT_MAX_TRACKED_KEYS: usize = 100_000;
110
111/// In-memory token-bucket `StateStore`: the OPEN single-node implementation. The
112/// refill-then-consume critical section is a short, synchronous map update (never
113/// held across `.await`), so `std::Mutex` is the right choice.
114pub struct InMemoryStateStore {
115    clock: Arc<dyn Clock>,
116    max_tracked_keys: usize,
117    inner: Mutex<InMemState>,
118}
119
120impl InMemoryStateStore {
121    pub fn new() -> Self {
122        Self::with_clock(Arc::new(SystemClock))
123    }
124
125    /// Construct with a custom clock (deterministic tests). The clock lives on the
126    /// store — time is part of how state advances, so it belongs with atomicity.
127    pub fn with_clock(clock: Arc<dyn Clock>) -> Self {
128        Self::with_clock_and_cap(clock, DEFAULT_MAX_TRACKED_KEYS)
129    }
130
131    pub fn with_clock_and_cap(clock: Arc<dyn Clock>, max_tracked_keys: usize) -> Self {
132        Self {
133            clock,
134            max_tracked_keys: max_tracked_keys.max(1),
135            inner: Mutex::new(InMemState { buckets: HashMap::new() }),
136        }
137    }
138
139    /// Number of currently tracked keys (tests/metrics). Concrete-only: a count is
140    /// cheap here but meaningless/expensive for a distributed store, so it is not
141    /// on the `StateStore` trait.
142    pub fn tracked_keys(&self) -> usize {
143        self.inner.lock().unwrap().buckets.len()
144    }
145
146    /// Drop idle (fully refilled) buckets. A bucket whose elapsed time exceeds the
147    /// full-refill duration is back at capacity — indistinguishable from a fresh
148    /// key — so it is safe to evict.
149    fn sweep_full_buckets(buckets: &mut HashMap<String, Bucket>, now: Instant, params: BucketParams) {
150        if params.refill_per_sec <= 0.0 {
151            return;
152        }
153        let full_refill = Duration::from_secs_f64(params.capacity / params.refill_per_sec);
154        buckets.retain(|_, b| now.duration_since(b.last) < full_refill);
155    }
156}
157
158impl Default for InMemoryStateStore {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164impl StateStore for InMemoryStateStore {
165    fn try_acquire(&self, key: &str, cost: f64, params: BucketParams) -> Acquired {
166        let now = self.clock.now();
167        let mut state = self.inner.lock().unwrap();
168
169        // Bound memory: before tracking a brand-new key at the cap, evict idle
170        // (fully refilled) buckets.
171        if !state.buckets.contains_key(key) && state.buckets.len() >= self.max_tracked_keys {
172            Self::sweep_full_buckets(&mut state.buckets, now, params);
173        }
174
175        let bucket = state
176            .buckets
177            .entry(key.to_string())
178            .or_insert(Bucket { tokens: params.capacity, last: now });
179
180        // Refill based on elapsed time, then attempt to consume.
181        let elapsed = now.duration_since(bucket.last).as_secs_f64();
182        bucket.tokens = (bucket.tokens + elapsed * params.refill_per_sec).min(params.capacity);
183        bucket.last = now;
184
185        if bucket.tokens >= cost {
186            bucket.tokens -= cost;
187            Acquired { allowed: true, tokens_remaining: bucket.tokens }
188        } else {
189            Acquired { allowed: false, tokens_remaining: bucket.tokens }
190        }
191    }
192}
193
194/// Shared, **non-reloadable** rate-limit store handle. Lives outside the module so
195/// a config hot reload (Fase 6 / Pillar 3) rebuilds the module's *parameters*
196/// (capacity/refill/action) while the buckets **survive** — otherwise an attacker
197/// could clear their own throttle by triggering a reload. Holds an
198/// `Arc<dyn StateStore>` so the enterprise can inject a distributed store without
199/// forking (the seam of BOUNDARY §4).
200#[derive(Clone)]
201pub struct RateLimitState(Arc<dyn StateStore>);
202
203impl RateLimitState {
204    /// In-memory store with the default tracked-key cap.
205    pub fn new() -> Self {
206        Self::in_memory(DEFAULT_MAX_TRACKED_KEYS)
207    }
208
209    /// In-memory store with an explicit tracked-key cap (system clock).
210    pub fn in_memory(max_tracked_keys: usize) -> Self {
211        Self::in_memory_with_clock(Arc::new(SystemClock), max_tracked_keys)
212    }
213
214    /// In-memory store with a custom clock + cap (deterministic tests).
215    pub fn in_memory_with_clock(clock: Arc<dyn Clock>, max_tracked_keys: usize) -> Self {
216        Self(Arc::new(InMemoryStateStore::with_clock_and_cap(clock, max_tracked_keys)))
217    }
218
219    /// Wrap an arbitrary store (the enterprise injection point).
220    pub fn with_store(store: Arc<dyn StateStore>) -> Self {
221        Self(store)
222    }
223
224    /// Delegate one atomic acquire to the underlying store. Exists so consumers in
225    /// other crates (the rate-limit module) reach the store without touching the
226    /// private handle — the store stays the single owner of the bucket state.
227    pub fn try_acquire(&self, key: &str, cost: f64, params: BucketParams) -> Acquired {
228        self.0.try_acquire(key, cost, params)
229    }
230}
231
232impl Default for RateLimitState {
233    fn default() -> Self {
234        Self::new()
235    }
236}