1use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum Check {
12 OsChanged,
14 BrowserChanged,
16 PlatformHintChanged,
18 InconsistentHints,
20 BrowserDowngraded,
22 UserAgentMissing,
24 MobileHintChanged,
26 CountryChanged,
28 ClientHintsMissing,
30 TlsChanged,
32 IpPingPong,
34 AsnChanged,
36 AcceptLanguageChanged,
38 IpPrefixChanged,
40}
41
42impl Check {
43 pub const ALL: [Check; 14] = [
45 Check::OsChanged,
46 Check::BrowserChanged,
47 Check::PlatformHintChanged,
48 Check::InconsistentHints,
49 Check::BrowserDowngraded,
50 Check::UserAgentMissing,
51 Check::MobileHintChanged,
52 Check::CountryChanged,
53 Check::ClientHintsMissing,
54 Check::TlsChanged,
55 Check::IpPingPong,
56 Check::AsnChanged,
57 Check::AcceptLanguageChanged,
58 Check::IpPrefixChanged,
59 ];
60
61 pub const fn default_weight(self) -> u32 {
63 match self {
64 Check::OsChanged | Check::BrowserChanged | Check::PlatformHintChanged => 80,
65 Check::InconsistentHints => 60,
66 Check::BrowserDowngraded | Check::UserAgentMissing | Check::MobileHintChanged => 50,
67 Check::CountryChanged => 40,
68 Check::ClientHintsMissing | Check::TlsChanged => 30,
69 Check::IpPingPong => 25,
70 Check::AsnChanged => 20,
71 Check::AcceptLanguageChanged => 15,
72 Check::IpPrefixChanged => 10,
73 }
74 }
75
76 pub const fn as_str(self) -> &'static str {
78 match self {
79 Check::OsChanged => "os_changed",
80 Check::BrowserChanged => "browser_changed",
81 Check::PlatformHintChanged => "platform_hint_changed",
82 Check::InconsistentHints => "inconsistent_hints",
83 Check::BrowserDowngraded => "browser_downgraded",
84 Check::UserAgentMissing => "user_agent_missing",
85 Check::MobileHintChanged => "mobile_hint_changed",
86 Check::CountryChanged => "country_changed",
87 Check::ClientHintsMissing => "client_hints_missing",
88 Check::TlsChanged => "tls_changed",
89 Check::IpPingPong => "ip_ping_pong",
90 Check::AsnChanged => "asn_changed",
91 Check::AcceptLanguageChanged => "accept_language_changed",
92 Check::IpPrefixChanged => "ip_prefix_changed",
93 }
94 }
95
96 const fn index(self) -> usize {
97 self as usize
98 }
99}
100
101impl fmt::Display for Check {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.write_str(self.as_str())
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
109#[error("unknown check {0:?}")]
110pub struct UnknownCheck(pub String);
111
112impl FromStr for Check {
113 type Err = UnknownCheck;
114
115 fn from_str(s: &str) -> Result<Self, Self::Err> {
116 Check::ALL
117 .into_iter()
118 .find(|c| c.as_str() == s)
119 .ok_or_else(|| UnknownCheck(s.to_owned()))
120 }
121}
122
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub struct Checks {
128 weights: [Option<u32>; 14],
129}
130
131impl Default for Checks {
132 fn default() -> Self {
134 Self {
135 weights: Check::ALL.map(|c| Some(c.default_weight())),
136 }
137 }
138}
139
140impl Checks {
141 pub const fn none() -> Self {
143 Self { weights: [None; 14] }
144 }
145
146 pub fn strict() -> Self {
150 Self::default()
151 .weight(Check::IpPrefixChanged, 40)
152 .weight(Check::AsnChanged, 40)
153 .weight(Check::AcceptLanguageChanged, 40)
154 .weight(Check::TlsChanged, 40)
155 .weight(Check::IpPingPong, 40)
156 .weight(Check::CountryChanged, 80)
157 }
158
159 #[must_use]
161 pub const fn disable(mut self, check: Check) -> Self {
162 self.weights[check.index()] = None;
163 self
164 }
165
166 #[must_use]
168 pub const fn enable(mut self, check: Check) -> Self {
169 if self.weights[check.index()].is_none() {
170 self.weights[check.index()] = Some(check.default_weight());
171 }
172 self
173 }
174
175 #[must_use]
177 pub const fn weight(mut self, check: Check, weight: u32) -> Self {
178 self.weights[check.index()] = Some(weight);
179 self
180 }
181
182 pub const fn is_enabled(&self, check: Check) -> bool {
184 self.weights[check.index()].is_some()
185 }
186
187 pub const fn weight_of(&self, check: Check) -> Option<u32> {
189 self.weights[check.index()]
190 }
191
192 pub(crate) const fn scores(&self, check: Check) -> bool {
194 matches!(self.weights[check.index()], Some(w) if w > 0)
195 }
196}
197
198#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
200pub enum Decision {
201 Allow,
203 StepUp,
205 Revoke,
207}
208
209impl Decision {
210 pub const fn as_str(self) -> &'static str {
212 match self {
213 Decision::Allow => "allow",
214 Decision::StepUp => "step_up",
215 Decision::Revoke => "revoke",
216 }
217 }
218}
219
220#[derive(Clone, Copy, Debug, PartialEq, Eq)]
222pub struct Thresholds {
223 pub step_up: u32,
225 pub revoke: u32,
227}
228
229impl Default for Thresholds {
230 fn default() -> Self {
231 Self {
232 step_up: 40,
233 revoke: 80,
234 }
235 }
236}
237
238impl Thresholds {
239 pub const fn decide(&self, score: u32) -> Decision {
241 if score >= self.revoke {
242 Decision::Revoke
243 } else if score >= self.step_up {
244 Decision::StepUp
245 } else {
246 Decision::Allow
247 }
248 }
249
250 pub(crate) const fn is_valid(self) -> bool {
251 self.step_up > 0 && self.step_up <= self.revoke
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn default_weights_match_the_spec() {
261 let c = Checks::default();
262 let expected = [80, 80, 80, 60, 50, 50, 50, 40, 30, 30, 25, 20, 15, 10];
263 for (check, w) in Check::ALL.into_iter().zip(expected) {
264 assert_eq!(c.weight_of(check), Some(w), "{check}");
265 }
266 }
267
268 #[test]
269 fn names_round_trip_and_match_serde() {
270 for check in Check::ALL {
271 assert_eq!(check.as_str().parse::<Check>(), Ok(check));
272 assert_eq!(
273 serde_json::to_string(&check).unwrap(),
274 format!("\"{}\"", check.as_str())
275 );
276 }
277 assert!("nope".parse::<Check>().is_err());
278 }
279
280 #[test]
281 fn enable_disable_and_weight() {
282 let c = Checks::default().disable(Check::AsnChanged);
283 assert!(!c.is_enabled(Check::AsnChanged));
284 assert_eq!(c.enable(Check::AsnChanged).weight_of(Check::AsnChanged), Some(20));
285 let c = Checks::none().weight(Check::OsChanged, 5).enable(Check::OsChanged);
286 assert_eq!(c.weight_of(Check::OsChanged), Some(5));
287 assert!(Check::ALL.into_iter().skip(1).all(|ch| !c.is_enabled(ch)));
288 }
289
290 #[test]
291 fn strict_raises_soft_signals() {
292 let s = Checks::strict();
293 assert_eq!(s.weight_of(Check::IpPrefixChanged), Some(40));
294 assert_eq!(s.weight_of(Check::CountryChanged), Some(80));
295 assert_eq!(s.weight_of(Check::OsChanged), Some(80));
296 }
297
298 #[test]
299 fn thresholds_decide() {
300 let t = Thresholds::default();
301 assert_eq!(t.decide(0), Decision::Allow);
302 assert_eq!(t.decide(39), Decision::Allow);
303 assert_eq!(t.decide(40), Decision::StepUp);
304 assert_eq!(t.decide(79), Decision::StepUp);
305 assert_eq!(t.decide(80), Decision::Revoke);
306 assert!(t.is_valid());
307 assert!(!Thresholds { step_up: 0, revoke: 10 }.is_valid());
308 assert!(
309 !Thresholds {
310 step_up: 50,
311 revoke: 10
312 }
313 .is_valid()
314 );
315 }
316}