Skip to main content

form_net/
digest.rs

1//! A count of refusals by code, for the detection layer: a route whose
2//! `syntax.*` or `policy.*` counts climb is being probed. Built on targets
3//! with atomic adds (every target but the smallest microcontrollers).
4
5use core::sync::atomic::Ordering;
6
7#[cfg(not(target_has_atomic = "64"))]
8use core::sync::atomic::AtomicU32 as Counter;
9#[cfg(target_has_atomic = "64")]
10use core::sync::atomic::AtomicU64 as Counter;
11
12use crate::error::{FormError, SLOT_CODES, SLOTS};
13
14/// How many times each failure code was returned, for one route.
15///
16/// One counter per code form-net returns, one per inspector code named in
17/// [`with_codes`](Self::with_codes), and one for every other inspector
18/// refusal. [`record`](Self::record) is a match and one relaxed atomic add:
19/// no allocation, no lock, safe to call from every thread at once. Keep one
20/// digest per route (a `static` works) and record every error the route
21/// returns, whichever call returned it; a detection layer reads the counts
22/// and compares them over time.
23///
24/// Counters are 64 bits where the target has 64-bit atomics and 32 bits
25/// elsewhere; they wrap, so compare readings with `wrapping_sub`.
26///
27/// # Examples
28///
29/// ```
30/// use form_net::RefusalDigest;
31///
32/// static LOGIN: RefusalDigest<1> = RefusalDigest::with_codes(["customs.unexpected_field"]);
33///
34/// for body in [&b"email=a%zz"[..], b"a=1", b"b=%"] {
35///     if let Err(e) = form_net::parse_urlencoded(body) {
36///         LOGIN.record(&e);
37///     }
38/// }
39/// assert_eq!(LOGIN.count("encoding.invalid_percent"), 2);
40/// assert_eq!(LOGIN.total(), 2);
41/// ```
42#[derive(Debug)]
43pub struct RefusalDigest<const N: usize = 0> {
44    form: [Counter; SLOTS],
45    codes: [&'static str; N],
46    named: [Counter; N],
47    unnamed: Counter,
48}
49
50impl RefusalDigest<0> {
51    /// A digest of form-net's own codes; every inspector refusal counts as
52    /// [`unnamed`](Self::unnamed).
53    #[must_use]
54    pub const fn new() -> RefusalDigest<0> {
55        RefusalDigest::with_codes([])
56    }
57}
58
59impl Default for RefusalDigest<0> {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl<const N: usize> RefusalDigest<N> {
66    /// A digest that also counts these inspector codes one by one (the codes
67    /// the route's inspectors can return, such as customs-sec's).
68    #[must_use]
69    pub const fn with_codes(codes: [&'static str; N]) -> RefusalDigest<N> {
70        RefusalDigest {
71            form: [const { Counter::new(0) }; SLOTS],
72            codes,
73            named: [const { Counter::new(0) }; N],
74            unnamed: Counter::new(0),
75        }
76    }
77
78    /// Counts `error` under its code.
79    ///
80    /// Cost: one match and one relaxed atomic add for form-net's codes; an
81    /// inspector refusal compares its code with the named codes first.
82    #[inline]
83    pub fn record(&self, error: &FormError) {
84        let counter = match error.slot() {
85            Some(slot) => self.form.get(slot),
86            None => {
87                let code = error.code();
88                self.codes
89                    .iter()
90                    .position(|c| *c == code)
91                    .and_then(|i| self.named.get(i))
92            }
93        };
94        counter
95            .unwrap_or(&self.unnamed)
96            .fetch_add(1, Ordering::Relaxed);
97    }
98
99    /// How many times `code` was recorded (form-net's codes and the named
100    /// inspector codes; a code named twice, or named and also form-net's,
101    /// sums its counters).
102    #[must_use]
103    pub fn count(&self, code: &str) -> u64 {
104        self.iter_all()
105            .filter(|(c, _)| *c == code)
106            .fold(0u64, |sum, (_, n)| sum.wrapping_add(n))
107    }
108
109    /// How many inspector refusals had a code that was not named.
110    #[must_use]
111    pub fn unnamed(&self) -> u64 {
112        load(&self.unnamed)
113    }
114
115    /// Everything recorded.
116    #[must_use]
117    pub fn total(&self) -> u64 {
118        self.iter_all()
119            .fold(self.unnamed(), |sum, (_, n)| sum.wrapping_add(n))
120    }
121
122    /// Every code with a count above zero, form-net's first in a fixed
123    /// order, then the named codes; [`unnamed`](Self::unnamed) is apart.
124    pub fn iter(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
125        self.iter_all().filter(|(_, n)| *n > 0)
126    }
127
128    fn iter_all(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
129        let form = SLOT_CODES.iter().copied().zip(self.form.iter().map(load));
130        let named = self.codes.iter().copied().zip(self.named.iter().map(load));
131        form.chain(named)
132    }
133}
134
135/// A counter's value, widened to 64 bits.
136#[inline]
137fn load(c: &Counter) -> u64 {
138    #[cfg(target_has_atomic = "64")]
139    let n = c.load(Ordering::Relaxed);
140    #[cfg(not(target_has_atomic = "64"))]
141    let n = u64::from(c.load(Ordering::Relaxed));
142    n
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::error::Location;
149    use crate::hook::Refusal;
150
151    fn refused(code: &'static str) -> FormError {
152        FormError::Refused {
153            refusal: Refusal::new(422, code),
154            at: Location::NONE,
155        }
156    }
157
158    #[test]
159    fn test_every_form_net_code_counts_in_its_own_counter() {
160        let digest = RefusalDigest::new();
161        let all = crate::error::tests::every_error();
162        for (i, e) in all.iter().enumerate() {
163            for _ in 0..=i {
164                digest.record(e);
165            }
166        }
167        for (i, e) in all.iter().enumerate() {
168            assert_eq!(digest.count(e.code()), i as u64 + 1, "{e:?}");
169        }
170        assert_eq!(digest.iter().count(), all.len());
171        assert_eq!(digest.unnamed(), 0);
172        let n = all.len() as u64;
173        assert_eq!(digest.total(), n * (n + 1) / 2);
174    }
175
176    #[test]
177    fn test_inspector_codes_count_by_name_and_the_rest_together() {
178        let digest = RefusalDigest::with_codes(["customs.a", "customs.b"]);
179        digest.record(&refused("customs.b"));
180        digest.record(&refused("customs.b"));
181        digest.record(&refused("customs.a"));
182        digest.record(&refused("customs.other"));
183        digest.record(&refused("csrf.rejected"));
184        assert_eq!(digest.count("customs.a"), 1);
185        assert_eq!(digest.count("customs.b"), 2);
186        assert_eq!(digest.count("customs.other"), 0);
187        assert_eq!(digest.unnamed(), 2);
188        assert_eq!(digest.total(), 5);
189        let seen: alloc::vec::Vec<_> = digest.iter().collect();
190        assert_eq!(seen, [("customs.a", 1), ("customs.b", 2)]);
191    }
192
193    #[test]
194    fn test_a_code_named_twice_or_shadowing_form_net_sums() {
195        let digest = RefusalDigest::with_codes(["x", "x", "limit.parts"]);
196        digest.record(&refused("x"));
197        digest.record(&refused("limit.parts"));
198        digest.record(&FormError::limit(crate::Limit::Parts, Location::NONE));
199        assert_eq!(digest.count("x"), 1);
200        assert_eq!(digest.count("limit.parts"), 2);
201        assert_eq!(digest.total(), 3);
202    }
203
204    #[test]
205    fn test_counts_from_many_threads_are_all_kept() {
206        static DIGEST: RefusalDigest = RefusalDigest::new();
207        let e = FormError::UnsupportedMediaType;
208        std::thread::scope(|s| {
209            for _ in 0..8 {
210                s.spawn(|| {
211                    for _ in 0..10_000 {
212                        DIGEST.record(&e);
213                    }
214                });
215            }
216        });
217        assert_eq!(DIGEST.count("unsupported_media_type"), 80_000);
218    }
219}