Skip to main content

clawft_kernel/
refusal_budget.rs

1//! Bound on chain writes caused by unauthenticated requests.
2//!
3//! A request that fails verification (bad signature, unknown controller,
4//! replayed nonce, expired) comes from someone this node has not
5//! authenticated. Chaining every one would let any peer that can reach the
6//! listener grow the node's chain without limit, so at most `per_window`
7//! such refusals are chained per window. The rest are counted, and the
8//! count rides on the next refusal that is chained (`suppressed`).
9//! Refusals of authenticated requests are always chained.
10
11use std::sync::Mutex;
12use std::time::{Duration, Instant};
13
14/// Default: chained verify refusals per window.
15pub const DEFAULT_PER_WINDOW: u32 = 16;
16/// Default window.
17pub const DEFAULT_WINDOW: Duration = Duration::from_secs(60);
18
19#[derive(Debug)]
20struct State {
21    start: Instant,
22    used: u32,
23    suppressed: u64,
24}
25
26/// Fixed-window budget.
27#[derive(Debug)]
28pub struct RefusalBudget {
29    per_window: u32,
30    window: Duration,
31    state: Mutex<State>,
32}
33
34impl Default for RefusalBudget {
35    fn default() -> Self {
36        Self::new(DEFAULT_PER_WINDOW, DEFAULT_WINDOW)
37    }
38}
39
40impl RefusalBudget {
41    /// `per_window` chain writes per `window`.
42    pub fn new(per_window: u32, window: Duration) -> Self {
43        Self {
44            per_window,
45            window,
46            state: Mutex::new(State {
47                start: Instant::now(),
48                used: 0,
49                suppressed: 0,
50            }),
51        }
52    }
53
54    /// `Some(suppressed since the last chained one)` if this refusal may be
55    /// chained now, else `None` (counted as suppressed).
56    pub fn take(&self) -> Option<u64> {
57        let mut s = self.state.lock().ok()?;
58        if s.start.elapsed() >= self.window {
59            s.start = Instant::now();
60            s.used = 0;
61        }
62        if s.used >= self.per_window {
63            if s.suppressed == 0 {
64                tracing::warn!("unauthenticated workload.ctl refusals over budget; not chaining");
65            }
66            s.suppressed = s.suppressed.saturating_add(1);
67            return None;
68        }
69        s.used += 1;
70        Some(std::mem::take(&mut s.suppressed))
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn bounded_per_window_and_the_suppressed_count_is_carried_forward() {
80        let b = RefusalBudget::new(2, Duration::from_millis(60));
81        assert_eq!(b.take(), Some(0));
82        assert_eq!(b.take(), Some(0));
83        for _ in 0..5 {
84            assert_eq!(b.take(), None);
85        }
86        std::thread::sleep(Duration::from_millis(80));
87        assert_eq!(b.take(), Some(5), "the next chained refusal reports 5");
88        assert_eq!(b.take(), Some(0));
89        assert_eq!(b.take(), None);
90    }
91}