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}