Skip to main content

security_rust/throttle/
store.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use super::StoreError;
4use std::collections::HashMap;
5use std::sync::Mutex;
6
7/// 限流状态后端。
8///
9/// 所有 `now` 均为 unix 秒,调用方须保证**单调不减**(系统时钟正常满足):
10/// 滑动窗口靠比较时间戳,时间倒流会让新记录被当成过期数据丢掉。
11pub trait ThrottleStore: Send + Sync {
12    /// 记录一次失败,返回**窗口内**的累计失败数(含本次)。
13    /// 实现须自行滑动窗口:只计 `now - window_secs` 之后的失败。
14    fn record_failure(&self, key: &str, now: u64, window_secs: u64) -> Result<u32, StoreError>;
15    /// 只读查询窗口内失败数,**不写入任何状态**。
16    ///
17    /// 供 `Throttle::check` 在放行时算剩余额度 —— 没有它,请求路径只能返回一个
18    /// 乐观的满额数字(`remaining` 会被写进 X-RateLimit 响应头,谎报等于误导调用方)。
19    fn failure_count(&self, key: &str, now: u64, window_secs: u64) -> Result<u32, StoreError>;
20    /// 该 key 是否处于封禁中;是则返回解封时刻。
21    ///
22    /// 返回**未过滤的原始值也是合法的**:`Throttle::check` 会自己拿 `now` 比对
23    /// `until` 再决定是否 `Banned`,后端不必代劳(返回 `Some(until)` 且
24    /// `until <= now` 不会被当成永久封禁)。
25    fn is_banned(&self, key: &str, now: u64) -> Result<Option<u64>, StoreError>;
26    /// 写入封禁截止时刻。**只能延长不能缩短**:已生效的封禁遇上更早的 `until`
27    /// 应被忽略(时钟回拨时 `now + ban_secs` 可能反而更小)。
28    fn ban(&self, key: &str, until: u64) -> Result<(), StoreError>;
29    /// 只清空失败计数,**保留封禁**。
30    ///
31    /// 认证成功走这里,人工解封走 `reset`:二者合并会让共享桶(如 `ip:`)的封禁
32    /// 被桶里任意另一个人一次成功认证解除。
33    fn clear_failures(&self, key: &str) -> Result<(), StoreError>;
34    /// 清零该 key 的失败计数与封禁。
35    fn reset(&self, key: &str) -> Result<(), StoreError>;
36    /// 清除已过期状态,返回清除条数。
37    fn purge_expired(&self, now: u64) -> Result<usize, StoreError>;
38}
39
40/// 某个 key 的限流状态。
41///
42/// 存**时间戳**而非计数:只有留下每次失败的坐标才能滑动窗口 ——
43/// 单纯自增的计数器没法判断「哪几次失败已经滑出窗口」,只能整段清零。
44#[derive(Debug, Default)]
45struct Entry {
46    /// 窗口内的失败时刻。时钟单调时才恰好是非递减的,回拨会破坏这个序 ——
47    /// 因此判定一律扫全量,不要用 `last()`。
48    failures: Vec<u64>,
49    /// 封禁截止时刻;`None` 表示从未封禁。只增不减:`ban` 取 max,时钟回拨
50    /// 不会把已生效的封禁缩短。
51    banned_until: Option<u64>,
52    /// 最近一次 `record_failure` 传入的窗口长度,仅供 `purge_expired` 判断陈旧。
53    /// store 不记住窗口,就无法区分「窗口内仍有效的失败」与「早该滑出的失败」,
54    /// 而 `purge_expired(now)` 的签名里没有窗口参数。
55    window_secs: u64,
56}
57
58/// 单个 key 的 [`Entry::failures`] 向量上限。
59///
60/// 为什么需要硬上限:`retain` 的代价是 O(保留条数),而保留条数取决于调用方传入的
61/// `now`。时钟冻结(或倒退)时没有任何失败会滑出窗口,向量随调用次数无上限增长
62/// —— 性能评审实测每次调用 2.5 万条 6.7µs、10 万条 52.9µs,且随条数线性恶化,
63/// 内存同理。有上限后单 key 的每次调用与占用都是常数级。
64///
65/// 上限为什么安全:封禁判定只看 `count >= threshold`,计数越过阈值后再涨没有区分度,
66/// 在饱和值停住即可 —— 于是**阈值 ≤ 本值的配置判定逐字不变**(默认 5,远在范围内),
67/// 只有把 threshold 配到 1024 以上的调用方才会观察到计数封顶、永不触发封禁。
68const MAX_TRACKED_FAILURES: usize = 1024;
69
70/// 内存后端。无后台线程,且**只有写路径**会清理:`record_failure` 顺手
71/// `retain` 掉滑出窗口的失败,`failure_count` / `is_banned` 这类读路径不碰状态。
72///
73/// 于是有两条边界:
74/// - 每个 key 的 `failures` 向量有界:每次 `record_failure` 都 retain,上界是
75///   `min(窗口内失败数, MAX_TRACKED_FAILURES)` —— 前者挡正常增长,后者挡时钟
76///   冻结/回拨、窗口整个失效时的无界增长;
77/// - **map 本身的条目数无上限** —— key 只增不减,只有 `purge_expired`(和
78///   `reset`)会移除条目。
79///
80/// 长期运行的进程必须按定时器调 `purge_expired`(间隔取 `window_secs` 量级即可),
81/// 否则 key 基数的增长会一直吃内存。
82#[derive(Debug)]
83pub struct MemoryThrottleStore {
84    entries: Mutex<HashMap<String, Entry>>,
85}
86
87impl Default for MemoryThrottleStore {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl MemoryThrottleStore {
94    pub fn new() -> Self {
95        Self {
96            entries: Mutex::new(HashMap::new()),
97        }
98    }
99
100    /// 互斥锁获取。线程 panic 导致锁中毒时恢复内部数据而非永久 Err:
101    /// 本 store 的每个操作都是单次 HashMap 读/写(外加一次与本次读写同批完成的
102    /// `Vec::retain`),不存在「改到一半」的不变量,恢复是安全的;
103    /// 而让一次 panic 永久锁死整个限流存储是一种自我 DoS。
104    fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
105        m.lock().unwrap_or_else(|e| e.into_inner())
106    }
107
108    /// 窗口下界:早于或等于它的失败都算滑出。
109    fn cutoff(now: u64, window_secs: u64) -> u64 {
110        now.saturating_sub(window_secs)
111    }
112}
113
114impl ThrottleStore for MemoryThrottleStore {
115    fn record_failure(&self, key: &str, now: u64, window_secs: u64) -> Result<u32, StoreError> {
116        let cutoff = Self::cutoff(now, window_secs);
117        let mut g = Self::lock(&self.entries);
118        let e = g.entry(key.to_string()).or_default();
119        e.window_secs = window_secs;
120        // ponytail: 每次失败做一次 O(保留条数) 的 retain,不需要后台清理线程。
121        // 天花板 = min(窗口内失败数, MAX_TRACKED_FAILURES):默认配置(窗口 60s、
122        // 每秒重试一次)约 60 条,单 key 几 KB。若 threshold 动到 1024 以上,
123        // 改环形缓冲或分桶计数(bucket 粒度 = window/10 的固定格子)。
124        e.failures.retain(|t| *t > cutoff);
125        // 封顶:满了就不再 push,计数在饱和值停住,而不是让向量无限长。
126        // 对阈值 ≤ 上限的配置,`count >= threshold` 的判定与不封顶时逐字相同
127        // (见 MAX_TRACKED_FAILURES 的说明)。
128        if e.failures.len() < MAX_TRACKED_FAILURES {
129            e.failures.push(now);
130        }
131        Ok(e.failures.len() as u32)
132    }
133
134    fn failure_count(&self, key: &str, now: u64, window_secs: u64) -> Result<u32, StoreError> {
135        let cutoff = Self::cutoff(now, window_secs);
136        let g = Self::lock(&self.entries);
137        Ok(g.get(key)
138            .map(|e| e.failures.iter().filter(|t| **t > cutoff).count() as u32)
139            .unwrap_or(0))
140    }
141
142    fn is_banned(&self, key: &str, now: u64) -> Result<Option<u64>, StoreError> {
143        Ok(Self::lock(&self.entries)
144            .get(key)
145            .and_then(|e| e.banned_until)
146            // `until` 是解封时刻:到点即解封,故严格大于
147            .filter(|until| *until > now))
148    }
149
150    fn ban(&self, key: &str, until: u64) -> Result<(), StoreError> {
151        let mut g = Self::lock(&self.entries);
152        let e = g.entry(key.to_string()).or_default();
153        // 取 max:时钟回拨后 `now + ban_secs` 可能早于已写入的 until,
154        // 无条件覆盖等于让攻击者靠回拨提前解封。
155        e.banned_until = Some(e.banned_until.map_or(until, |existing| existing.max(until)));
156        Ok(())
157    }
158
159    fn clear_failures(&self, key: &str) -> Result<(), StoreError> {
160        if let Some(e) = Self::lock(&self.entries).get_mut(key) {
161            e.failures.clear();
162        }
163        Ok(())
164    }
165
166    fn reset(&self, key: &str) -> Result<(), StoreError> {
167        Self::lock(&self.entries).remove(key);
168        Ok(())
169    }
170
171    fn purge_expired(&self, now: u64) -> Result<usize, StoreError> {
172        let mut g = Self::lock(&self.entries);
173        let before = g.len();
174        g.retain(|_, e| {
175            // 封禁未到期 ⇒ 留着;否则只要窗口内还有失败也留着。
176            // 两者都不成立才叫「过期」—— 只清封禁记录而不看失败,会把还在
177            // 计数窗口内的对手顺手洗白,等于给攻击者一个免费的计数重置。
178            let banned = e.banned_until.is_some_and(|until| until > now);
179            // 扫全量而非取 `last()`:`failures` 只在时钟单调时才是非递减的,
180            // 回拨会让 `last()` 变成最小值,把窗口内的有效失败整条丢掉。
181            let fresh = e
182                .failures
183                .iter()
184                .any(|t| *t > Self::cutoff(now, e.window_secs));
185            banned || fresh
186        });
187        Ok(before - g.len())
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::throttle::{Throttle, ThrottleConfig, ThrottleDecision};
195
196    const NOW: u64 = 1_000_000;
197
198    fn store() -> MemoryThrottleStore {
199        MemoryThrottleStore::new()
200    }
201
202    #[test]
203    fn record_failure_counts_within_window() {
204        let s = store();
205        assert_eq!(s.record_failure("k", NOW, 60).unwrap(), 1);
206        assert_eq!(s.record_failure("k", NOW + 1, 60).unwrap(), 2);
207    }
208
209    #[test]
210    fn failures_at_window_edge_are_dropped() {
211        let s = store();
212        s.record_failure("k", NOW, 60).unwrap();
213        // 恰好 window_secs 秒前的失败算滑出(保留条件是 t > now - window)
214        assert_eq!(s.record_failure("k", NOW + 60, 60).unwrap(), 1);
215        // 窗口内一秒之差则仍计入
216        assert_eq!(s.record_failure("k", NOW + 61, 60).unwrap(), 2);
217    }
218
219    #[test]
220    fn failure_count_is_read_only() {
221        let s = store();
222        assert_eq!(s.failure_count("k", NOW, 60).unwrap(), 0);
223        s.record_failure("k", NOW, 60).unwrap();
224        assert_eq!(s.failure_count("k", NOW, 60).unwrap(), 1);
225        // 查十次也不该把计数查大
226        for _ in 0..10 {
227            assert_eq!(s.failure_count("k", NOW, 60).unwrap(), 1);
228        }
229        assert_eq!(s.record_failure("k", NOW, 60).unwrap(), 2);
230    }
231
232    #[test]
233    fn failure_count_of_unknown_key_is_zero() {
234        let s = store();
235        assert_eq!(s.failure_count("ghost", NOW, 60).unwrap(), 0);
236    }
237
238    #[test]
239    fn failures_vec_stays_bounded_to_window() {
240        // 有界性:窗口外的失败必须真被丢掉,否则 Vec 无上限增长
241        let s = store();
242        for i in 0..100 {
243            s.record_failure("k", NOW + i, 60).unwrap();
244        }
245        // 每秒一次、窗口 60 秒 ⇒ 至多留下 60 条,而不是 100 条
246        assert_eq!(len(&s, "k"), 60, "窗口外的失败必须真被丢弃");
247        // 跳到窗口之外再来一次,前面 60 条也应全部被清掉
248        s.record_failure("k", NOW + 1_000, 60).unwrap();
249        assert_eq!(len(&s, "k"), 1);
250    }
251
252    #[test]
253    fn frozen_clock_growth_is_capped_and_threshold_verdict_unchanged() {
254        // 时钟冻结 ⇒ 没有失败滑出窗口 ⇒ 只有硬上限挡得住无界增长
255        let s = store();
256        let mut count = 0;
257        for _ in 0..(MAX_TRACKED_FAILURES + 100) {
258            count = s.record_failure("k", NOW, 60).unwrap();
259        }
260        assert_eq!(len(&s, "k"), MAX_TRACKED_FAILURES, "failures 必须有硬上限");
261        assert_eq!(count, MAX_TRACKED_FAILURES as u32, "计数在饱和值上停住");
262
263        // 阈值 ≤ 上限时封禁判定不受影响:饱和计数照样越线,
264        // Banned 与未封顶时完全相同(阈值 5 在第 5 次就封)
265        let t = Throttle::new(
266            MemoryThrottleStore::new(),
267            ThrottleConfig {
268                threshold: 5,
269                window_secs: 60,
270                ban_secs: 900,
271            },
272        );
273        for _ in 0..(MAX_TRACKED_FAILURES + 100) {
274            t.record_failure("k", NOW).unwrap();
275        }
276        assert_eq!(
277            t.check("k", NOW),
278            ThrottleDecision::Banned { until: NOW + 900 },
279            "饱和不得改变封禁判定"
280        );
281    }
282
283    #[test]
284    fn banned_expires_at_until_exclusive() {
285        let s = store();
286        s.ban("k", NOW + 100).unwrap();
287        assert_eq!(s.is_banned("k", NOW).unwrap(), Some(NOW + 100));
288        assert_eq!(s.is_banned("k", NOW + 99).unwrap(), Some(NOW + 100));
289        assert_eq!(s.is_banned("k", NOW + 100).unwrap(), None);
290    }
291
292    #[test]
293    fn is_banned_unknown_key_is_none() {
294        let s = store();
295        assert_eq!(s.is_banned("ghost", NOW).unwrap(), None);
296    }
297
298    #[test]
299    fn reset_clears_failures_and_ban() {
300        let s = store();
301        s.record_failure("k", NOW, 60).unwrap();
302        s.ban("k", NOW + 100).unwrap();
303        s.reset("k").unwrap();
304        assert_eq!(s.failure_count("k", NOW, 60).unwrap(), 0);
305        assert_eq!(s.is_banned("k", NOW).unwrap(), None);
306    }
307
308    #[test]
309    fn reset_unknown_key_is_ok() {
310        let s = store();
311        assert!(s.reset("ghost").is_ok());
312    }
313
314    #[test]
315    fn purge_keeps_banned_and_fresh_entries() {
316        let s = store();
317        s.record_failure("stale", NOW, 60).unwrap();
318        s.record_failure("fresh", NOW + 990, 60).unwrap();
319        s.ban("banned", NOW + 5_000).unwrap();
320        assert_eq!(s.purge_expired(NOW + 1_000).unwrap(), 1);
321        assert_eq!(s.failure_count("stale", NOW + 1_000, 60).unwrap(), 0);
322        assert_eq!(s.failure_count("fresh", NOW + 1_000, 60).unwrap(), 1);
323        assert!(s.is_banned("banned", NOW + 1_000).unwrap().is_some());
324    }
325
326    #[test]
327    fn purge_with_expired_ban_drops_entry() {
328        let s = store();
329        s.record_failure("k", NOW, 60).unwrap();
330        s.ban("k", NOW + 10).unwrap();
331        // 封禁已到期且失败已滑出窗口 ⇒ 状态全过期
332        assert_eq!(s.purge_expired(NOW + 100).unwrap(), 1);
333        assert_eq!(s.is_banned("k", NOW + 100).unwrap(), None);
334    }
335
336    #[test]
337    fn purge_empty_store_is_zero() {
338        let s = store();
339        assert_eq!(s.purge_expired(NOW).unwrap(), 0);
340    }
341
342    #[test]
343    fn purge_keeps_in_window_failures_after_clock_rollback() {
344        // 回拨让 failures 乱序成 [NOW, NOW-200]:取 `last()` 会拿到最小值,
345        // 把仍在窗口内的 [NOW] 判成过期 —— 攻击者只要诱发一次回拨再等一次
346        // purge,计数就被免费重置。判定必须扫全量。
347        let s = store();
348        s.record_failure("k", NOW, 60).unwrap();
349        s.record_failure("k", NOW - 200, 60).unwrap();
350        assert_eq!(s.failure_count("k", NOW, 60).unwrap(), 1);
351        assert_eq!(
352            s.purge_expired(NOW).unwrap(),
353            0,
354            "窗口内仍有有效失败,不该清"
355        );
356        assert_eq!(
357            s.failure_count("k", NOW, 60).unwrap(),
358            1,
359            "计数不能被 purge 免费重置"
360        );
361    }
362
363    #[test]
364    fn ban_cannot_be_shortened_by_clock_rollback() {
365        let s = store();
366        s.ban("k", NOW + 900).unwrap();
367        // 回拨后重新封禁:天真的 `until = now + ban_secs` 会写进一个更早的时刻
368        s.ban("k", NOW - 5_000 + 900).unwrap();
369        assert_eq!(
370            s.is_banned("k", NOW).unwrap(),
371            Some(NOW + 900),
372            "封禁只能延长,回拨不能提前解封"
373        );
374    }
375
376    #[test]
377    fn empty_key_is_a_normal_key() {
378        // 空 key 不做特殊处理:调用方负责构造非空且不重名的 key
379        let s = store();
380        assert_eq!(s.record_failure("", NOW, 60).unwrap(), 1);
381        assert_eq!(s.record_failure("", NOW, 60).unwrap(), 2);
382        assert_eq!(s.record_failure("k", NOW, 60).unwrap(), 1);
383    }
384
385    #[test]
386    fn lock_recovers_from_poisoned_mutex() {
387        // 钉住 lock() 的恢复不变量:一次 panic 不能永久锁死限流存储
388        let m = Mutex::new(Entry {
389            failures: vec![NOW],
390            banned_until: Some(NOW + 1),
391            window_secs: 60,
392        });
393        std::panic::catch_unwind(|| {
394            let _guard = m.lock().unwrap();
395            panic!("poison");
396        })
397        .unwrap_err();
398        assert!(m.is_poisoned());
399
400        let g = MemoryThrottleStore::lock(&m);
401        assert_eq!(g.failures, vec![NOW]);
402        assert_eq!(g.banned_until, Some(NOW + 1));
403    }
404
405    /// 直接读私有字段,只有单测能这么干。
406    fn len(s: &MemoryThrottleStore, key: &str) -> usize {
407        MemoryThrottleStore::lock(&s.entries)
408            .get(key)
409            .map(|e| e.failures.len())
410            .unwrap_or(0)
411    }
412}