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::{Anonymity, 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 pub anonymity: Option<Anonymity>,
43}
44
45impl Fingerprint {
46 pub fn capture(ctx: &ClientContext, info: &IpInfo) -> Self {
48 Self::build(
49 ctx,
50 info,
51 ctx.user_agent.as_deref().map(parse_user_agent).unwrap_or_default(),
52 )
53 }
54
55 pub(crate) fn capture_cached(ctx: &ClientContext, info: &IpInfo, cache: &UaCache) -> Self {
56 Self::build(
57 ctx,
58 info,
59 ctx.user_agent.as_deref().map(|ua| cache.parse(ua)).unwrap_or_default(),
60 )
61 }
62
63 fn build(ctx: &ClientContext, info: &IpInfo, ua: ParsedUa) -> Self {
64 Self {
65 ip_prefix: ip_prefix(ctx.ip),
66 country: info.country.clone().filter(|c| !c.is_empty()),
67 asn: info.asn,
68 browser: ua.browser,
69 browser_major: ua.major,
70 os: ua.os,
71 ch_platform: ctx.ch_platform.as_deref().and_then(platform_family),
72 ch_mobile: ctx.ch_mobile,
73 accept_language: ctx.accept_language.as_deref().and_then(normalise_languages),
74 tls: ctx.tls_fingerprint.clone().filter(|t| !t.is_empty()),
75 anonymity: info.anonymity,
76 }
77 }
78}
79
80#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(default)]
87#[non_exhaustive]
88pub struct Baseline {
89 pub fingerprint: Fingerprint,
91 pub previous_ip_prefix: Option<String>,
93 pub ip_changed_at: Option<DateTime<Utc>>,
95}
96
97impl Baseline {
98 pub fn new(fingerprint: Fingerprint) -> Self {
100 Self {
101 fingerprint,
102 previous_ip_prefix: None,
103 ip_changed_at: None,
104 }
105 }
106
107 pub fn absorb(&mut self, current: &Fingerprint, now: DateTime<Utc>, checks: &Checks) -> bool {
112 let before = self.clone();
113 let fp = &mut self.fingerprint;
114 let ip_scored = checks.scores(Check::IpPrefixChanged) || checks.scores(Check::IpPingPong);
115 if ip_scored && fp.ip_prefix != current.ip_prefix && !current.ip_prefix.is_empty() {
116 self.previous_ip_prefix = Some(std::mem::replace(&mut fp.ip_prefix, current.ip_prefix.clone()));
117 self.ip_changed_at = Some(now);
118 }
119 if checks.scores(Check::CountryChanged) {
120 keep_known(&mut fp.country, current.country.as_ref());
121 }
122 if checks.scores(Check::AsnChanged) {
123 keep_known(&mut fp.asn, current.asn.as_ref());
124 }
125 if let (Some(was), Some(now)) = (fp.anonymity, current.anonymity)
126 && anonymity_change(was, now).is_some_and(|check| !checks.scores(check))
127 {
128 } else {
130 keep_known(&mut fp.anonymity, current.anonymity.as_ref());
131 }
132 if checks.scores(Check::AcceptLanguageChanged) {
133 keep_known(&mut fp.accept_language, current.accept_language.as_ref());
134 }
135 if checks.scores(Check::TlsChanged) {
136 keep_known(&mut fp.tls, current.tls.as_ref());
137 }
138 if checks.scores(Check::BrowserDowngraded) {
139 fp.browser_major = fp.browser_major.max(current.browser_major);
140 }
141 *self != before
142 }
143}
144
145fn keep_known<T: Clone>(slot: &mut Option<T>, value: Option<&T>) {
146 if let Some(v) = value {
147 *slot = Some(v.clone());
148 }
149}
150
151pub(crate) fn anonymity_change(was: Anonymity, now: Anonymity) -> Option<Check> {
155 use Anonymity::{Direct, HostingProvider, PublicProxy, ResidentialProxy, TorExitNode, Vpn};
156 match (was, now) {
157 (TorExitNode | PublicProxy, _) => None,
158 (_, TorExitNode | PublicProxy) => Some(Check::TorOrProxyEntered),
159 (Direct, Vpn | HostingProvider | ResidentialProxy) => Some(Check::AnonymizerEntered),
160 _ => None,
161 }
162}
163
164pub fn ip_prefix(ip: IpAddr) -> String {
166 let len = if ip.is_ipv4() { 24 } else { 48 };
167 IpNet::new(ip, len).map_or_else(|_| ip.to_string(), |net| net.trunc().to_string())
168}
169
170pub(crate) fn os_compatible(a: &str, b: &str) -> bool {
172 a == b
173 || matches!(
174 (a, b),
175 ("Android", "Linux") | ("Linux", "Android") | ("iOS", "macOS") | ("macOS", "iOS")
176 )
177}
178
179#[derive(Clone, Debug, Default, PartialEq, Eq)]
180pub(crate) struct ParsedUa {
181 browser: Option<String>,
182 major: Option<u32>,
183 os: Option<String>,
184}
185
186fn parse_user_agent(ua: &str) -> ParsedUa {
187 let Some(parsed) = woothee::parser::Parser::new().parse(ua) else {
188 return ParsedUa::default();
189 };
190 let known = |v: &str| (!v.is_empty() && v != woothee::woothee::VALUE_UNKNOWN).then(|| v.to_owned());
191 let browser = known(parsed.name);
192 ParsedUa {
193 major: browser
194 .as_ref()
195 .and(parsed.version.split('.').next())
196 .and_then(|m| m.parse().ok()),
197 os: known(parsed.os).map(|os| os_family(&os)),
198 browser,
199 }
200}
201
202pub(crate) struct UaCache(Cache<String, ParsedUa>);
204
205impl UaCache {
206 pub(crate) fn new(capacity: usize) -> Self {
207 Self(Cache::new(capacity.max(1)))
208 }
209
210 fn parse(&self, ua: &str) -> ParsedUa {
211 if let Some(hit) = self.0.get(ua) {
212 return hit;
213 }
214 let parsed = parse_user_agent(ua);
215 self.0.insert(ua.to_owned(), parsed.clone());
216 parsed
217 }
218}
219
220impl std::fmt::Debug for UaCache {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 f.debug_struct("UaCache").field("len", &self.0.len()).finish()
223 }
224}
225
226fn os_family(os: &str) -> String {
227 match os {
228 os if os.starts_with("Windows") => "Windows",
229 "Mac OSX" | "Mac OS Classic" => "macOS",
230 "iPhone" | "iPad" | "iPod" | "iOS" => "iOS",
231 "ChromeOS" => "ChromeOS",
232 other => other,
233 }
234 .to_owned()
235}
236
237fn platform_family(platform: &str) -> Option<String> {
238 match platform.trim() {
239 "Unknown" | "" => None,
240 "Chrome OS" | "Chromium OS" => Some("ChromeOS".to_owned()),
241 other => Some(other.to_owned()),
242 }
243}
244
245fn normalise_languages(header: &str) -> Option<String> {
246 let tags: Vec<String> = header
247 .split(',')
248 .filter_map(|part| part.split(';').next())
249 .map(|tag| tag.trim().to_ascii_lowercase())
250 .filter(|tag| !tag.is_empty() && tag != "*")
251 .take(3)
252 .collect();
253 (!tags.is_empty()).then(|| tags.join(","))
254}
255
256#[cfg(test)]
257pub(crate) mod tests {
258 use super::*;
259 use crate::intel::Anonymity;
260
261 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";
262
263 fn ctx(ua: Option<&str>) -> ClientContext {
264 let mut ctx = ClientContext::new("203.0.113.77".parse().unwrap())
265 .with_ch_platform("\"Windows\"")
266 .with_ch_mobile(false)
267 .with_accept_language("nl-BE,nl;q=0.9,en-US;q=0.8,en;q=0.7");
268 ctx.user_agent = ua.map(str::to_owned);
269 ctx
270 }
271
272 #[test]
273 fn captures_normalised_signals() {
274 let fp = Fingerprint::capture(&ctx(Some(CHROME_WIN)), &IpInfo::new(Some("BE".into()), Some(5432)));
275 assert_eq!(fp.ip_prefix, "203.0.113.0/24");
276 assert_eq!(fp.country.as_deref(), Some("BE"));
277 assert_eq!(fp.asn, Some(5432));
278 assert_eq!(fp.browser.as_deref(), Some("Chrome"));
279 assert_eq!(fp.browser_major, Some(141));
280 assert_eq!(fp.os.as_deref(), Some("Windows"));
281 assert_eq!(fp.ch_platform.as_deref(), Some("Windows"));
282 assert_eq!(fp.accept_language.as_deref(), Some("nl-be,nl,en-us"));
283 assert_eq!(fp.anonymity, None);
284
285 let info = IpInfo::default().with_anonymity(Anonymity::Vpn);
286 let fp = Fingerprint::capture(&ctx(Some(CHROME_WIN)), &info);
287 assert_eq!(fp.anonymity, Some(Anonymity::Vpn));
288 }
289
290 #[test]
291 fn cached_capture_matches_uncached() {
292 let cache = UaCache::new(8);
293 let a = Fingerprint::capture(&ctx(Some(CHROME_WIN)), &IpInfo::default());
294 let b = Fingerprint::capture_cached(&ctx(Some(CHROME_WIN)), &IpInfo::default(), &cache);
295 let c = Fingerprint::capture_cached(&ctx(Some(CHROME_WIN)), &IpInfo::default(), &cache);
296 assert_eq!(a, b);
297 assert_eq!(b, c);
298 }
299
300 #[test]
301 fn garbage_user_agent_yields_unknowns() {
302 let fp = Fingerprint::capture(&ctx(Some("definitely not a browser")), &IpInfo::default());
303 assert_eq!((fp.browser, fp.browser_major, fp.os), (None, None, None));
304 let fp = Fingerprint::capture(&ctx(None), &IpInfo::default());
305 assert_eq!(fp.browser, None);
306 }
307
308 #[test]
309 fn os_families_and_platforms() {
310 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";
311 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";
312 assert_eq!(
313 Fingerprint::capture(&ctx(Some(mac)), &IpInfo::default()).os.as_deref(),
314 Some("macOS")
315 );
316 assert_eq!(
317 Fingerprint::capture(&ctx(Some(iphone)), &IpInfo::default())
318 .os
319 .as_deref(),
320 Some("iOS")
321 );
322 assert_eq!(platform_family("Chrome OS").as_deref(), Some("ChromeOS"));
323 assert_eq!(platform_family("Unknown"), None);
324 assert!(os_compatible("Android", "Linux") && os_compatible("macOS", "iOS"));
325 assert!(!os_compatible("Windows", "macOS"));
326 }
327
328 #[test]
329 fn ip_prefixes() {
330 assert_eq!(ip_prefix("2001:db8:abcd:12::1".parse().unwrap()), "2001:db8:abcd::/48");
331 assert_eq!(ip_prefix("10.1.2.3".parse().unwrap()), "10.1.2.0/24");
332 }
333
334 #[test]
335 fn languages_are_normalised() {
336 assert_eq!(normalise_languages("*"), None);
337 assert_eq!(normalise_languages(" EN ; q=1, fr").as_deref(), Some("en,fr"));
338 }
339
340 fn base() -> Fingerprint {
341 Fingerprint::capture(&ctx(Some(CHROME_WIN)), &IpInfo::new(Some("BE".into()), Some(5432)))
342 }
343
344 #[test]
345 fn absorb_tracks_moves_and_never_downgrades_version() {
346 let now = Utc::now();
347 let original = base();
348 let mut baseline = Baseline::new(original.clone());
349
350 let mut moved = original.clone();
351 moved.ip_prefix = "198.51.100.0/24".to_owned();
352 moved.asn = Some(6848);
353 moved.country = None;
354 moved.browser_major = Some(140);
355 moved.accept_language = None;
356 assert!(baseline.absorb(&moved, now, &Checks::default()));
357
358 let fp = &baseline.fingerprint;
359 assert_eq!(fp.ip_prefix, "198.51.100.0/24");
360 assert_eq!(baseline.previous_ip_prefix.as_deref(), Some("203.0.113.0/24"));
361 assert_eq!(baseline.ip_changed_at, Some(now));
362 assert_eq!(fp.asn, Some(6848));
363 assert_eq!(fp.country.as_deref(), Some("BE"), "missing country never erases");
364 assert_eq!(fp.browser_major, Some(141));
365 assert_eq!(fp.accept_language, original.accept_language);
366 }
367
368 #[test]
369 fn absorb_never_learns_hard_identity() {
370 let mut baseline = Baseline::new(base());
371 let mut other = base();
372 other.browser = Some("Firefox".into());
373 other.os = Some("Linux".into());
374 other.ch_platform = Some("Linux".into());
375 other.ch_mobile = Some(true);
376 assert!(!baseline.absorb(&other, Utc::now(), &Checks::default()));
377 assert_eq!(baseline.fingerprint, base());
378 }
379
380 #[test]
381 fn absorb_learns_only_scored_signals() {
382 let original = base();
383 let mut baseline = Baseline::new(original.clone());
384 let mut moved = original.clone();
385 moved.ip_prefix = "198.51.100.0/24".to_owned();
386 moved.asn = Some(6848);
387 moved.accept_language = Some("de-DE".into());
388 let checks = Checks::default()
389 .disable(Check::AsnChanged)
390 .disable(Check::IpPrefixChanged)
391 .disable(Check::IpPingPong)
392 .weight(Check::AcceptLanguageChanged, 0);
393 assert!(!baseline.absorb(&moved, Utc::now(), &checks));
394 assert_eq!(baseline.fingerprint, original);
395
396 let ping_pong_only = Checks::none().enable(Check::IpPingPong);
397 assert!(baseline.absorb(&moved, Utc::now(), &ping_pong_only));
398 assert_eq!(baseline.fingerprint.ip_prefix, "198.51.100.0/24");
399 assert_eq!(baseline.fingerprint.asn, original.asn);
400 }
401
402 #[test]
403 fn absorb_learns_anonymity_only_when_its_check_scored() {
404 let direct = || {
405 let mut fp = base();
406 fp.anonymity = Some(Anonymity::Direct);
407 fp
408 };
409 let with = |anonymity| {
410 let mut fp = direct();
411 fp.ip_prefix = "198.51.100.0/24".to_owned();
412 fp.anonymity = anonymity;
413 fp
414 };
415 let now = Utc::now();
416
417 let mut baseline = Baseline::new(direct());
418 let loose = Checks::default().disable(Check::AnonymizerEntered);
419 baseline.absorb(&with(Some(Anonymity::Vpn)), now, &loose);
420 assert_eq!(baseline.fingerprint.anonymity, Some(Anonymity::Direct), "unscored move");
421 baseline.absorb(&with(Some(Anonymity::Vpn)), now, &Checks::default());
422 assert_eq!(baseline.fingerprint.anonymity, Some(Anonymity::Vpn));
423
424 baseline.absorb(&with(None), now, &Checks::none());
426 assert_eq!(
427 baseline.fingerprint.anonymity,
428 Some(Anonymity::Vpn),
429 "missing never erases"
430 );
431 baseline.absorb(&with(Some(Anonymity::Direct)), now, &Checks::none());
432 assert_eq!(baseline.fingerprint.anonymity, Some(Anonymity::Direct));
433
434 let mut unknown = Baseline::new(base());
435 unknown.absorb(&with(Some(Anonymity::TorExitNode)), now, &Checks::none());
436 assert_eq!(unknown.fingerprint.anonymity, Some(Anonymity::TorExitNode));
437 }
438
439 #[test]
440 fn absorb_reports_no_change_for_identical_request() {
441 let mut baseline = Baseline::new(base());
442 assert!(!baseline.absorb(&base(), Utc::now(), &Checks::default()));
443 }
444
445 #[test]
446 fn baseline_round_trips_json_and_tolerates_missing_fields() {
447 let b = Baseline::new(base());
448 let json = serde_json::to_string(&b).unwrap();
449 assert_eq!(serde_json::from_str::<Baseline>(&json).unwrap(), b);
450 assert_eq!(serde_json::from_str::<Baseline>("{}").unwrap(), Baseline::default());
451
452 let mut fp = base();
453 fp.anonymity = Some(Anonymity::ResidentialProxy);
454 let json = serde_json::to_value(&fp).unwrap();
455 assert_eq!(json["anonymity"], "residential_proxy");
456 assert_eq!(serde_json::from_value::<Fingerprint>(json).unwrap(), fp);
457 }
458}