1use std::net::IpAddr;
4
5use chrono::{DateTime, Utc};
6use ipnet::IpNet;
7use quick_cache::sync::Cache;
8use serde::{Deserialize, Serialize};
9
10use crate::{
11 checks::{Check, Checks},
12 client::ClientContext,
13 intel::IpInfo,
14};
15
16#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(default)]
19#[non_exhaustive]
20pub struct Fingerprint {
21 pub ip_prefix: String,
23 pub country: Option<String>,
25 pub asn: Option<u32>,
27 pub browser: Option<String>,
29 pub browser_major: Option<u32>,
31 pub os: Option<String>,
33 pub ch_platform: Option<String>,
35 pub ch_mobile: Option<bool>,
37 pub accept_language: Option<String>,
39 pub tls: Option<String>,
41}
42
43impl Fingerprint {
44 pub fn capture(ctx: &ClientContext, info: IpInfo) -> Self {
46 Self::build(
47 ctx,
48 info,
49 ctx.user_agent.as_deref().map(parse_user_agent).unwrap_or_default(),
50 )
51 }
52
53 pub(crate) fn capture_cached(ctx: &ClientContext, info: IpInfo, cache: &UaCache) -> Self {
54 Self::build(
55 ctx,
56 info,
57 ctx.user_agent.as_deref().map(|ua| cache.parse(ua)).unwrap_or_default(),
58 )
59 }
60
61 fn build(ctx: &ClientContext, info: IpInfo, ua: ParsedUa) -> Self {
62 Self {
63 ip_prefix: ip_prefix(ctx.ip),
64 country: info.country.filter(|c| !c.is_empty()),
65 asn: info.asn,
66 browser: ua.browser,
67 browser_major: ua.major,
68 os: ua.os,
69 ch_platform: ctx.ch_platform.as_deref().and_then(platform_family),
70 ch_mobile: ctx.ch_mobile,
71 accept_language: ctx.accept_language.as_deref().and_then(normalise_languages),
72 tls: ctx.tls_fingerprint.clone().filter(|t| !t.is_empty()),
73 }
74 }
75}
76
77#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(default)]
84#[non_exhaustive]
85pub struct Baseline {
86 pub fingerprint: Fingerprint,
88 pub previous_ip_prefix: Option<String>,
90 pub ip_changed_at: Option<DateTime<Utc>>,
92}
93
94impl Baseline {
95 pub fn new(fingerprint: Fingerprint) -> Self {
97 Self {
98 fingerprint,
99 previous_ip_prefix: None,
100 ip_changed_at: None,
101 }
102 }
103
104 pub fn absorb(&mut self, current: &Fingerprint, now: DateTime<Utc>, checks: &Checks) -> bool {
109 let before = self.clone();
110 let fp = &mut self.fingerprint;
111 let ip_scored = checks.scores(Check::IpPrefixChanged) || checks.scores(Check::IpPingPong);
112 if ip_scored && fp.ip_prefix != current.ip_prefix && !current.ip_prefix.is_empty() {
113 self.previous_ip_prefix = Some(std::mem::replace(&mut fp.ip_prefix, current.ip_prefix.clone()));
114 self.ip_changed_at = Some(now);
115 }
116 if checks.scores(Check::CountryChanged) {
117 keep_known(&mut fp.country, current.country.as_ref());
118 }
119 if checks.scores(Check::AsnChanged) {
120 keep_known(&mut fp.asn, current.asn.as_ref());
121 }
122 if checks.scores(Check::AcceptLanguageChanged) {
123 keep_known(&mut fp.accept_language, current.accept_language.as_ref());
124 }
125 if checks.scores(Check::TlsChanged) {
126 keep_known(&mut fp.tls, current.tls.as_ref());
127 }
128 if checks.scores(Check::BrowserDowngraded) {
129 fp.browser_major = fp.browser_major.max(current.browser_major);
130 }
131 *self != before
132 }
133}
134
135fn keep_known<T: Clone>(slot: &mut Option<T>, value: Option<&T>) {
136 if let Some(v) = value {
137 *slot = Some(v.clone());
138 }
139}
140
141pub fn ip_prefix(ip: IpAddr) -> String {
143 let len = if ip.is_ipv4() { 24 } else { 48 };
144 IpNet::new(ip, len).map_or_else(|_| ip.to_string(), |net| net.trunc().to_string())
145}
146
147pub(crate) fn os_compatible(a: &str, b: &str) -> bool {
149 a == b
150 || matches!(
151 (a, b),
152 ("Android", "Linux") | ("Linux", "Android") | ("iOS", "macOS") | ("macOS", "iOS")
153 )
154}
155
156#[derive(Clone, Debug, Default, PartialEq, Eq)]
157pub(crate) struct ParsedUa {
158 browser: Option<String>,
159 major: Option<u32>,
160 os: Option<String>,
161}
162
163fn parse_user_agent(ua: &str) -> ParsedUa {
164 let Some(parsed) = woothee::parser::Parser::new().parse(ua) else {
165 return ParsedUa::default();
166 };
167 let known = |v: &str| (!v.is_empty() && v != woothee::woothee::VALUE_UNKNOWN).then(|| v.to_owned());
168 let browser = known(parsed.name);
169 ParsedUa {
170 major: browser
171 .as_ref()
172 .and(parsed.version.split('.').next())
173 .and_then(|m| m.parse().ok()),
174 os: known(parsed.os).map(|os| os_family(&os)),
175 browser,
176 }
177}
178
179pub(crate) struct UaCache(Cache<String, ParsedUa>);
181
182impl UaCache {
183 pub(crate) fn new(capacity: usize) -> Self {
184 Self(Cache::new(capacity.max(1)))
185 }
186
187 fn parse(&self, ua: &str) -> ParsedUa {
188 if let Some(hit) = self.0.get(ua) {
189 return hit;
190 }
191 let parsed = parse_user_agent(ua);
192 self.0.insert(ua.to_owned(), parsed.clone());
193 parsed
194 }
195}
196
197impl std::fmt::Debug for UaCache {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 f.debug_struct("UaCache").field("len", &self.0.len()).finish()
200 }
201}
202
203fn os_family(os: &str) -> String {
204 match os {
205 os if os.starts_with("Windows") => "Windows",
206 "Mac OSX" | "Mac OS Classic" => "macOS",
207 "iPhone" | "iPad" | "iPod" | "iOS" => "iOS",
208 "ChromeOS" => "ChromeOS",
209 other => other,
210 }
211 .to_owned()
212}
213
214fn platform_family(platform: &str) -> Option<String> {
215 match platform.trim() {
216 "Unknown" | "" => None,
217 "Chrome OS" | "Chromium OS" => Some("ChromeOS".to_owned()),
218 other => Some(other.to_owned()),
219 }
220}
221
222fn normalise_languages(header: &str) -> Option<String> {
223 let tags: Vec<String> = header
224 .split(',')
225 .filter_map(|part| part.split(';').next())
226 .map(|tag| tag.trim().to_ascii_lowercase())
227 .filter(|tag| !tag.is_empty() && tag != "*")
228 .take(3)
229 .collect();
230 (!tags.is_empty()).then(|| tags.join(","))
231}
232
233#[cfg(test)]
234pub(crate) mod tests {
235 use super::*;
236
237 pub(crate) const CHROME_WIN: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
238
239 fn ctx(ua: Option<&str>) -> ClientContext {
240 let mut ctx = ClientContext::new("203.0.113.77".parse().unwrap())
241 .with_ch_platform("\"Windows\"")
242 .with_ch_mobile(false)
243 .with_accept_language("nl-BE,nl;q=0.9,en-US;q=0.8,en;q=0.7");
244 ctx.user_agent = ua.map(str::to_owned);
245 ctx
246 }
247
248 #[test]
249 fn captures_normalised_signals() {
250 let fp = Fingerprint::capture(&ctx(Some(CHROME_WIN)), IpInfo::new(Some("BE".into()), Some(5432)));
251 assert_eq!(fp.ip_prefix, "203.0.113.0/24");
252 assert_eq!(fp.country.as_deref(), Some("BE"));
253 assert_eq!(fp.asn, Some(5432));
254 assert_eq!(fp.browser.as_deref(), Some("Chrome"));
255 assert_eq!(fp.browser_major, Some(141));
256 assert_eq!(fp.os.as_deref(), Some("Windows"));
257 assert_eq!(fp.ch_platform.as_deref(), Some("Windows"));
258 assert_eq!(fp.accept_language.as_deref(), Some("nl-be,nl,en-us"));
259 }
260
261 #[test]
262 fn cached_capture_matches_uncached() {
263 let cache = UaCache::new(8);
264 let a = Fingerprint::capture(&ctx(Some(CHROME_WIN)), IpInfo::default());
265 let b = Fingerprint::capture_cached(&ctx(Some(CHROME_WIN)), IpInfo::default(), &cache);
266 let c = Fingerprint::capture_cached(&ctx(Some(CHROME_WIN)), IpInfo::default(), &cache);
267 assert_eq!(a, b);
268 assert_eq!(b, c);
269 }
270
271 #[test]
272 fn garbage_user_agent_yields_unknowns() {
273 let fp = Fingerprint::capture(&ctx(Some("definitely not a browser")), IpInfo::default());
274 assert_eq!((fp.browser, fp.browser_major, fp.os), (None, None, None));
275 let fp = Fingerprint::capture(&ctx(None), IpInfo::default());
276 assert_eq!(fp.browser, None);
277 }
278
279 #[test]
280 fn os_families_and_platforms() {
281 let mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
282 let iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1";
283 assert_eq!(
284 Fingerprint::capture(&ctx(Some(mac)), IpInfo::default()).os.as_deref(),
285 Some("macOS")
286 );
287 assert_eq!(
288 Fingerprint::capture(&ctx(Some(iphone)), IpInfo::default())
289 .os
290 .as_deref(),
291 Some("iOS")
292 );
293 assert_eq!(platform_family("Chrome OS").as_deref(), Some("ChromeOS"));
294 assert_eq!(platform_family("Unknown"), None);
295 assert!(os_compatible("Android", "Linux") && os_compatible("macOS", "iOS"));
296 assert!(!os_compatible("Windows", "macOS"));
297 }
298
299 #[test]
300 fn ip_prefixes() {
301 assert_eq!(ip_prefix("2001:db8:abcd:12::1".parse().unwrap()), "2001:db8:abcd::/48");
302 assert_eq!(ip_prefix("10.1.2.3".parse().unwrap()), "10.1.2.0/24");
303 }
304
305 #[test]
306 fn languages_are_normalised() {
307 assert_eq!(normalise_languages("*"), None);
308 assert_eq!(normalise_languages(" EN ; q=1, fr").as_deref(), Some("en,fr"));
309 }
310
311 fn base() -> Fingerprint {
312 Fingerprint::capture(&ctx(Some(CHROME_WIN)), IpInfo::new(Some("BE".into()), Some(5432)))
313 }
314
315 #[test]
316 fn absorb_tracks_moves_and_never_downgrades_version() {
317 let now = Utc::now();
318 let original = base();
319 let mut baseline = Baseline::new(original.clone());
320
321 let mut moved = original.clone();
322 moved.ip_prefix = "198.51.100.0/24".to_owned();
323 moved.asn = Some(6848);
324 moved.country = None;
325 moved.browser_major = Some(140);
326 moved.accept_language = None;
327 assert!(baseline.absorb(&moved, now, &Checks::default()));
328
329 let fp = &baseline.fingerprint;
330 assert_eq!(fp.ip_prefix, "198.51.100.0/24");
331 assert_eq!(baseline.previous_ip_prefix.as_deref(), Some("203.0.113.0/24"));
332 assert_eq!(baseline.ip_changed_at, Some(now));
333 assert_eq!(fp.asn, Some(6848));
334 assert_eq!(fp.country.as_deref(), Some("BE"), "missing country never erases");
335 assert_eq!(fp.browser_major, Some(141));
336 assert_eq!(fp.accept_language, original.accept_language);
337 }
338
339 #[test]
340 fn absorb_never_learns_hard_identity() {
341 let mut baseline = Baseline::new(base());
342 let mut other = base();
343 other.browser = Some("Firefox".into());
344 other.os = Some("Linux".into());
345 other.ch_platform = Some("Linux".into());
346 other.ch_mobile = Some(true);
347 assert!(!baseline.absorb(&other, Utc::now(), &Checks::default()));
348 assert_eq!(baseline.fingerprint, base());
349 }
350
351 #[test]
352 fn absorb_learns_only_scored_signals() {
353 let original = base();
354 let mut baseline = Baseline::new(original.clone());
355 let mut moved = original.clone();
356 moved.ip_prefix = "198.51.100.0/24".to_owned();
357 moved.asn = Some(6848);
358 moved.accept_language = Some("de-DE".into());
359 let checks = Checks::default()
360 .disable(Check::AsnChanged)
361 .disable(Check::IpPrefixChanged)
362 .disable(Check::IpPingPong)
363 .weight(Check::AcceptLanguageChanged, 0);
364 assert!(!baseline.absorb(&moved, Utc::now(), &checks));
365 assert_eq!(baseline.fingerprint, original);
366
367 let ping_pong_only = Checks::none().enable(Check::IpPingPong);
368 assert!(baseline.absorb(&moved, Utc::now(), &ping_pong_only));
369 assert_eq!(baseline.fingerprint.ip_prefix, "198.51.100.0/24");
370 assert_eq!(baseline.fingerprint.asn, original.asn);
371 }
372
373 #[test]
374 fn absorb_reports_no_change_for_identical_request() {
375 let mut baseline = Baseline::new(base());
376 assert!(!baseline.absorb(&base(), Utc::now(), &Checks::default()));
377 }
378
379 #[test]
380 fn baseline_round_trips_json_and_tolerates_missing_fields() {
381 let b = Baseline::new(base());
382 let json = serde_json::to_string(&b).unwrap();
383 assert_eq!(serde_json::from_str::<Baseline>(&json).unwrap(), b);
384 assert_eq!(serde_json::from_str::<Baseline>("{}").unwrap(), Baseline::default());
385 }
386}