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    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use form_net::RefusalDigest;
58    ///
59    /// let digest = RefusalDigest::new();
60    /// digest.record(&form_net::parse_urlencoded(b"a=%").unwrap_err());
61    /// assert_eq!(digest.count("encoding.invalid_percent"), 1);
62    /// ```
63    #[must_use]
64    pub const fn new() -> RefusalDigest<0> {
65        RefusalDigest::with_codes([])
66    }
67}
68
69impl Default for RefusalDigest<0> {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl<const N: usize> RefusalDigest<N> {
76    /// A digest that also counts these inspector codes one by one (the codes
77    /// the route's inspectors can return, such as customs-sec's).
78    ///
79    /// # Examples
80    ///
81    /// ```
82    /// use form_net::{FormError, Location, Refusal, RefusalDigest};
83    ///
84    /// let digest = RefusalDigest::with_codes(["customs.too_many"]);
85    /// digest.record(&FormError::Refused { refusal: Refusal::new(400, "customs.too_many"), at: Location::NONE });
86    /// assert_eq!(digest.count("customs.too_many"), 1);
87    /// assert_eq!(digest.unnamed(), 0);
88    /// ```
89    #[must_use]
90    pub const fn with_codes(codes: [&'static str; N]) -> RefusalDigest<N> {
91        RefusalDigest {
92            form: [const { Counter::new(0) }; SLOTS],
93            codes,
94            named: [const { Counter::new(0) }; N],
95            unnamed: Counter::new(0),
96        }
97    }
98
99    /// Counts `error` under its code.
100    ///
101    /// Cost: one match and one relaxed atomic add for form-net's codes; an
102    /// inspector refusal compares its code with the named codes first.
103    ///
104    /// # Examples
105    ///
106    /// ```
107    /// let digest = form_net::RefusalDigest::new();
108    /// if let Err(e) = form_net::parse_urlencoded(b"a=%zz") {
109    ///     digest.record(&e);
110    /// }
111    /// assert_eq!(digest.total(), 1);
112    /// ```
113    #[inline]
114    pub fn record(&self, error: &FormError) {
115        let counter = match error.slot() {
116            Some(slot) => self.form.get(slot),
117            None => {
118                let code = error.code();
119                self.codes
120                    .iter()
121                    .position(|c| *c == code)
122                    .and_then(|i| self.named.get(i))
123            }
124        };
125        counter
126            .unwrap_or(&self.unnamed)
127            .fetch_add(1, Ordering::Relaxed);
128    }
129
130    /// How many times `code` was recorded (form-net's codes and the named
131    /// inspector codes; a code named twice, or named and also form-net's,
132    /// sums its counters).
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// let digest = form_net::RefusalDigest::new();
138    /// digest.record(&form_net::parse_urlencoded(b"a=%").unwrap_err());
139    /// assert_eq!(digest.count("encoding.invalid_percent"), 1);
140    /// assert_eq!(digest.count("limit.body"), 0);
141    /// ```
142    #[must_use]
143    pub fn count(&self, code: &str) -> u64 {
144        self.iter_all()
145            .filter(|(c, _)| *c == code)
146            .fold(0u64, |sum, (_, n)| sum.wrapping_add(n))
147    }
148
149    /// How many inspector refusals had a code that was not named.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use form_net::{FormError, Location, Refusal, RefusalDigest};
155    ///
156    /// let digest = RefusalDigest::new();
157    /// digest.record(&FormError::Refused { refusal: Refusal::new(403, "app.blocked"), at: Location::NONE });
158    /// assert_eq!(digest.unnamed(), 1);
159    /// ```
160    #[must_use]
161    pub fn unnamed(&self) -> u64 {
162        load(&self.unnamed)
163    }
164
165    /// Everything recorded.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use form_net::{FormError, Location, Refusal, RefusalDigest};
171    ///
172    /// let digest = RefusalDigest::new();
173    /// digest.record(&form_net::parse_urlencoded(b"a=%").unwrap_err());
174    /// digest.record(&FormError::Refused { refusal: Refusal::new(403, "app.blocked"), at: Location::NONE });
175    /// assert_eq!(digest.total(), 2);
176    /// ```
177    #[must_use]
178    pub fn total(&self) -> u64 {
179        self.iter_all()
180            .fold(self.unnamed(), |sum, (_, n)| sum.wrapping_add(n))
181    }
182
183    /// Every code with a count above zero, form-net's first in a fixed
184    /// order, then the named codes; [`unnamed`](Self::unnamed) is apart.
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// let digest = form_net::RefusalDigest::new();
190    /// digest.record(&form_net::parse_urlencoded(b"a=%").unwrap_err());
191    /// let counts: Vec<_> = digest.iter().collect();
192    /// assert_eq!(counts, [("encoding.invalid_percent", 1)]);
193    /// ```
194    pub fn iter(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
195        self.iter_all().filter(|(_, n)| *n > 0)
196    }
197
198    fn iter_all(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
199        let form = SLOT_CODES.iter().copied().zip(self.form.iter().map(load));
200        let named = self.codes.iter().copied().zip(self.named.iter().map(load));
201        form.chain(named)
202    }
203}
204
205/// A counter's value, widened to 64 bits.
206#[inline]
207fn load(c: &Counter) -> u64 {
208    #[cfg(target_has_atomic = "64")]
209    let n = c.load(Ordering::Relaxed);
210    #[cfg(not(target_has_atomic = "64"))]
211    let n = u64::from(c.load(Ordering::Relaxed));
212    n
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::error::Location;
219    use crate::hook::Refusal;
220
221    fn refused(code: &'static str) -> FormError {
222        FormError::Refused {
223            refusal: Refusal::new(422, code),
224            at: Location::NONE,
225        }
226    }
227
228    #[test]
229    fn test_every_form_net_code_counts_in_its_own_counter() {
230        let digest = RefusalDigest::new();
231        let all = crate::error::tests::every_error();
232        for (i, e) in all.iter().enumerate() {
233            for _ in 0..=i {
234                digest.record(e);
235            }
236        }
237        for (i, e) in all.iter().enumerate() {
238            assert_eq!(digest.count(e.code()), i as u64 + 1, "{e:?}");
239        }
240        assert_eq!(digest.iter().count(), all.len());
241        assert_eq!(digest.unnamed(), 0);
242        let n = all.len() as u64;
243        assert_eq!(digest.total(), n * (n + 1) / 2);
244    }
245
246    #[test]
247    fn test_inspector_codes_count_by_name_and_the_rest_together() {
248        let digest = RefusalDigest::with_codes(["customs.a", "customs.b"]);
249        digest.record(&refused("customs.b"));
250        digest.record(&refused("customs.b"));
251        digest.record(&refused("customs.a"));
252        digest.record(&refused("customs.other"));
253        digest.record(&refused("csrf.rejected"));
254        assert_eq!(digest.count("customs.a"), 1);
255        assert_eq!(digest.count("customs.b"), 2);
256        assert_eq!(digest.count("customs.other"), 0);
257        assert_eq!(digest.unnamed(), 2);
258        assert_eq!(digest.total(), 5);
259        let seen: alloc::vec::Vec<_> = digest.iter().collect();
260        assert_eq!(seen, [("customs.a", 1), ("customs.b", 2)]);
261    }
262
263    #[test]
264    fn test_a_code_named_twice_or_shadowing_form_net_sums() {
265        let digest = RefusalDigest::with_codes(["x", "x", "limit.parts"]);
266        digest.record(&refused("x"));
267        digest.record(&refused("limit.parts"));
268        digest.record(&FormError::limit(crate::Limit::Parts, Location::NONE));
269        assert_eq!(digest.count("x"), 1);
270        assert_eq!(digest.count("limit.parts"), 2);
271        assert_eq!(digest.total(), 3);
272    }
273
274    #[test]
275    fn test_counts_from_many_threads_are_all_kept() {
276        static DIGEST: RefusalDigest = RefusalDigest::new();
277        let e = FormError::UnsupportedMediaType;
278        std::thread::scope(|s| {
279            for _ in 0..8 {
280                s.spawn(|| {
281                    for _ in 0..10_000 {
282                        DIGEST.record(&e);
283                    }
284                });
285            }
286        });
287        assert_eq!(DIGEST.count("unsupported_media_type"), 80_000);
288    }
289}