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}