1use serde::{Deserialize, Serialize};
19use std::fmt::Write as _;
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use crate::freshness::signature_hash;
23
24const SCREEN_RESOLUTIONS: &[(u32, u32)] = &[
27 (1920, 1080),
28 (2560, 1440),
29 (1440, 900),
30 (1366, 768),
31 (1536, 864),
32 (1280, 800),
33 (2560, 1600),
34 (1680, 1050),
35];
36
37const TIMEZONES: &[&str] = &[
38 "America/New_York",
39 "America/Chicago",
40 "America/Denver",
41 "America/Los_Angeles",
42 "Europe/London",
43 "Europe/Paris",
44 "Europe/Berlin",
45 "Asia/Tokyo",
46 "Asia/Shanghai",
47 "Australia/Sydney",
48];
49
50const LANGUAGES: &[&str] = &[
51 "en-US", "en-GB", "en-AU", "en-CA", "fr-FR", "de-DE", "es-ES", "it-IT", "pt-BR", "ja-JP",
52 "zh-CN",
53];
54
55const HARDWARE_CONCURRENCY: &[u32] = &[4, 8, 12, 16];
56const DEVICE_MEMORY: &[u32] = &[4, 8, 16];
57
58const WEBGL_PROFILES: &[(&str, &str, &str)] = &[
60 ("Intel Inc.", "Intel Iris OpenGL Engine", "MacIntel"),
61 ("Intel Inc.", "Intel UHD Graphics 630", "MacIntel"),
62 (
63 "Google Inc. (Apple)",
64 "ANGLE (Apple, Apple M2, OpenGL 4.1)",
65 "MacIntel",
66 ),
67 (
68 "Google Inc. (NVIDIA)",
69 "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080, OpenGL 4.1)",
70 "Win32",
71 ),
72 (
73 "Google Inc. (Intel)",
74 "ANGLE (Intel, Intel(R) UHD Graphics 770, OpenGL 4.6)",
75 "Win32",
76 ),
77 (
78 "Google Inc. (AMD)",
79 "ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0)",
80 "Win32",
81 ),
82];
83
84const WINDOWS_WEBGL_PROFILES: &[(&str, &str)] = &[
86 (
87 "Google Inc. (NVIDIA)",
88 "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080, OpenGL 4.1)",
89 ),
90 (
91 "Google Inc. (Intel)",
92 "ANGLE (Intel, Intel(R) UHD Graphics 770, OpenGL 4.6)",
93 ),
94 (
95 "Google Inc. (AMD)",
96 "ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0)",
97 ),
98];
99
100const MACOS_WEBGL_PROFILES: &[(&str, &str)] = &[
102 ("Intel Inc.", "Intel Iris OpenGL Engine"),
103 ("Intel Inc.", "Intel UHD Graphics 630"),
104 ("Google Inc. (Apple)", "ANGLE (Apple, Apple M2, OpenGL 4.1)"),
105];
106
107const MOBILE_ANDROID_RESOLUTIONS: &[(u32, u32)] =
109 &[(393, 851), (390, 844), (412, 915), (414, 896), (360, 780)];
110
111const MOBILE_IOS_RESOLUTIONS: &[(u32, u32)] =
112 &[(390, 844), (393, 852), (375, 667), (414, 896), (428, 926)];
113
114const ANDROID_WEBGL_PROFILES: &[(&str, &str)] = &[
116 ("Qualcomm", "Adreno (TM) 730"),
117 ("ARM", "Mali-G710 MC10"),
118 (
119 "Google Inc. (Qualcomm)",
120 "ANGLE (Qualcomm, Adreno (TM) 730, OpenGL ES 3.2)",
121 ),
122 ("Google Inc. (ARM)", "ANGLE (ARM, Mali-G610, OpenGL ES 3.2)"),
123];
124
125const IOS_WEBGL_PROFILES: &[(&str, &str)] = &[
126 ("Apple Inc.", "Apple A16 GPU"),
127 ("Apple Inc.", "Apple A15 GPU"),
128 ("Apple Inc.", "Apple A14 GPU"),
129 ("Apple Inc.", "Apple M1"),
130];
131
132const WINDOWS_FONTS: &[&str] = &[
134 "Arial",
135 "Calibri",
136 "Cambria",
137 "Comic Sans MS",
138 "Consolas",
139 "Courier New",
140 "Georgia",
141 "Impact",
142 "Segoe UI",
143 "Tahoma",
144 "Times New Roman",
145 "Trebuchet MS",
146 "Verdana",
147];
148
149const MACOS_FONTS: &[&str] = &[
150 "Arial",
151 "Avenir",
152 "Baskerville",
153 "Courier New",
154 "Futura",
155 "Georgia",
156 "Helvetica Neue",
157 "Lucida Grande",
158 "Optima",
159 "Palatino",
160 "Times New Roman",
161 "Verdana",
162];
163
164const LINUX_FONTS: &[&str] = &[
165 "Arial",
166 "DejaVu Sans",
167 "DejaVu Serif",
168 "FreeMono",
169 "Liberation Mono",
170 "Liberation Sans",
171 "Liberation Serif",
172 "Times New Roman",
173 "Ubuntu",
174];
175
176const MOBILE_ANDROID_FONTS: &[&str] = &[
177 "Roboto",
178 "Noto Sans",
179 "Droid Sans",
180 "sans-serif",
181 "serif",
182 "monospace",
183];
184
185const MOBILE_IOS_FONTS: &[&str] = &[
186 "Helvetica Neue",
187 "Arial",
188 "Georgia",
189 "Times New Roman",
190 "Courier New",
191];
192
193const CHROME_VERSIONS: &[u32] = &[120, 121, 122, 123, 124, 125];
195const EDGE_VERSIONS: &[u32] = &[120, 121, 122, 123, 124];
196const FIREFOX_VERSIONS: &[u32] = &[121, 122, 123, 124, 125, 126];
197const SAFARI_VERSIONS: &[&str] = &["17.0", "17.1", "17.2", "17.3", "17.4"];
198const IOS_OS_VERSIONS: &[&str] = &["16_6", "17_0", "17_1", "17_2", "17_3"];
199
200const fn rng(seed: u64, step: u64) -> u64 {
205 let x = seed.wrapping_add(step.wrapping_mul(0x9e37_79b9_7f4a_7c15));
206 let x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
207 let x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
208 x ^ (x >> 31)
209}
210
211fn pick<T: Copy + Default>(items: &[T], entropy: u64) -> T {
212 let idx = usize::try_from(entropy).unwrap_or(usize::MAX) % items.len().max(1);
213 items.get(idx).copied().unwrap_or_default()
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Fingerprint {
231 pub user_agent: String,
233
234 pub screen_resolution: (u32, u32),
236
237 pub timezone: String,
239
240 pub language: String,
242
243 pub platform: String,
245
246 pub hardware_concurrency: u32,
248
249 pub device_memory: u32,
251
252 pub webgl_vendor: Option<String>,
254
255 pub webgl_renderer: Option<String>,
257
258 pub canvas_noise: bool,
260
261 pub fonts: Vec<String>,
266}
267
268impl Default for Fingerprint {
269 fn default() -> Self {
270 Self {
271 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
272 AppleWebKit/537.36 (KHTML, like Gecko) \
273 Chrome/120.0.0.0 Safari/537.36"
274 .to_string(),
275 screen_resolution: (1920, 1080),
276 timezone: "America/New_York".to_string(),
277 language: "en-US".to_string(),
278 platform: "MacIntel".to_string(),
279 hardware_concurrency: 8,
280 device_memory: 8,
281 webgl_vendor: Some("Intel Inc.".to_string()),
282 webgl_renderer: Some("Intel Iris OpenGL Engine".to_string()),
283 canvas_noise: true,
284 fonts: vec![],
285 }
286 }
287}
288
289impl Fingerprint {
290 #[must_use]
306 pub fn random() -> Self {
307 let seed = SystemTime::now()
308 .duration_since(UNIX_EPOCH)
309 .map_or(0x5a5a_5a5a_5a5a_5a5a, |d| {
310 d.as_secs() ^ u64::from(d.subsec_nanos())
311 });
312
313 let res = pick(SCREEN_RESOLUTIONS, rng(seed, 1));
314 let tz = pick(TIMEZONES, rng(seed, 2));
315 let lang = pick(LANGUAGES, rng(seed, 3));
316 let hw = pick(HARDWARE_CONCURRENCY, rng(seed, 4));
317 let dm = pick(DEVICE_MEMORY, rng(seed, 5));
318 let (wv, wr, platform) = pick(WEBGL_PROFILES, rng(seed, 6));
319
320 let user_agent = if platform == "Win32" {
321 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
322 AppleWebKit/537.36 (KHTML, like Gecko) \
323 Chrome/120.0.0.0 Safari/537.36"
324 .to_string()
325 } else {
326 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
327 AppleWebKit/537.36 (KHTML, like Gecko) \
328 Chrome/120.0.0.0 Safari/537.36"
329 .to_string()
330 };
331
332 let fonts: Vec<String> = if platform == "Win32" {
333 WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect()
334 } else {
335 MACOS_FONTS.iter().map(|s| (*s).to_string()).collect()
336 };
337
338 Self {
339 user_agent,
340 screen_resolution: res,
341 timezone: tz.to_string(),
342 language: lang.to_string(),
343 platform: platform.to_string(),
344 hardware_concurrency: hw,
345 device_memory: dm,
346 webgl_vendor: Some(wv.to_string()),
347 webgl_renderer: Some(wr.to_string()),
348 canvas_noise: true,
349 fonts,
350 }
351 }
352
353 #[must_use]
365 pub fn from_profile(profile: &FingerprintProfile) -> Self {
366 profile.fingerprint.clone()
367 }
368
369 #[must_use]
384 pub fn from_device_profile(device: DeviceProfile, seed: u64) -> Self {
385 match device {
386 DeviceProfile::DesktopWindows => Self::for_windows(seed),
387 DeviceProfile::DesktopMac => Self::for_mac(seed),
388 DeviceProfile::DesktopLinux => Self::for_linux(seed),
389 DeviceProfile::MobileAndroid => Self::for_android(seed),
390 DeviceProfile::MobileIOS => Self::for_ios(seed),
391 }
392 }
393
394 #[must_use]
408 pub fn validate_consistency(&self) -> Vec<String> {
409 let mut issues = Vec::new();
410
411 if self.platform == "Win32" && self.user_agent.contains("Mac OS X") {
413 issues.push("Win32 platform but user-agent says Mac OS X".to_string());
414 }
415 if self.platform == "MacIntel" && self.user_agent.contains("Windows NT") {
416 issues.push("MacIntel platform but user-agent says Windows NT".to_string());
417 }
418 if self.platform.starts_with("Linux") && self.user_agent.contains("Windows NT") {
419 issues.push("Linux platform but user-agent says Windows NT".to_string());
420 }
421
422 if let Some(vendor) = &self.webgl_vendor {
424 if (self.platform == "Win32" || self.platform == "MacIntel")
425 && (vendor.contains("Qualcomm")
426 || vendor.contains("Adreno")
427 || vendor.contains("Mali"))
428 {
429 issues.push(format!(
430 "Desktop platform '{}' has mobile GPU vendor '{vendor}'",
431 self.platform
432 ));
433 }
434 if self.platform == "Win32" && vendor.starts_with("Apple") {
435 issues.push(format!("Win32 platform has Apple GPU vendor '{vendor}'"));
436 }
437 }
438
439 if !self.fonts.is_empty() {
441 let has_win_exclusive = self
442 .fonts
443 .iter()
444 .any(|f| matches!(f.as_str(), "Segoe UI" | "Calibri" | "Consolas" | "Tahoma"));
445 let has_mac_exclusive = self.fonts.iter().any(|f| {
446 matches!(
447 f.as_str(),
448 "Lucida Grande" | "Avenir" | "Optima" | "Futura" | "Baskerville"
449 )
450 });
451 let has_linux_exclusive = self.fonts.iter().any(|f| {
452 matches!(
453 f.as_str(),
454 "DejaVu Sans" | "Liberation Sans" | "Ubuntu" | "FreeMono"
455 )
456 });
457
458 if self.platform == "MacIntel" && has_win_exclusive {
459 issues.push("MacIntel platform has Windows-exclusive fonts".to_string());
460 }
461 if self.platform == "Win32" && has_mac_exclusive {
462 issues.push("Win32 platform has macOS-exclusive fonts".to_string());
463 }
464 if self.platform == "Win32" && has_linux_exclusive {
465 issues.push("Win32 platform has Linux-exclusive fonts".to_string());
466 }
467 }
468
469 issues
470 }
471
472 fn for_windows(seed: u64) -> Self {
475 let browser = BrowserKind::for_device(DeviceProfile::DesktopWindows, seed);
476 let user_agent = match browser {
477 BrowserKind::Chrome | BrowserKind::Safari => {
478 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
479 format!(
480 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
481 AppleWebKit/537.36 (KHTML, like Gecko) \
482 Chrome/{ver}.0.0.0 Safari/537.36"
483 )
484 }
485 BrowserKind::Edge => {
486 let ver = pick(EDGE_VERSIONS, rng(seed, 10));
487 format!(
488 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
489 AppleWebKit/537.36 (KHTML, like Gecko) \
490 Chrome/{ver}.0.0.0 Safari/537.36 Edg/{ver}.0.0.0"
491 )
492 }
493 BrowserKind::Firefox => {
494 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
495 format!(
496 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{ver}.0) \
497 Gecko/20100101 Firefox/{ver}.0"
498 )
499 }
500 };
501
502 let (webgl_vendor, webgl_renderer) = pick(WINDOWS_WEBGL_PROFILES, rng(seed, 7));
503 let fonts = WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect();
504
505 Self {
506 user_agent,
507 screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
508 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
509 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
510 platform: "Win32".to_string(),
511 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
512 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
513 webgl_vendor: Some(webgl_vendor.to_string()),
514 webgl_renderer: Some(webgl_renderer.to_string()),
515 canvas_noise: true,
516 fonts,
517 }
518 }
519
520 fn for_mac(seed: u64) -> Self {
521 let browser = BrowserKind::for_device(DeviceProfile::DesktopMac, seed);
522 let user_agent = match browser {
523 BrowserKind::Chrome | BrowserKind::Edge => {
524 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
525 format!(
526 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
527 AppleWebKit/537.36 (KHTML, like Gecko) \
528 Chrome/{ver}.0.0.0 Safari/537.36"
529 )
530 }
531 BrowserKind::Safari => {
532 let ver = pick(SAFARI_VERSIONS, rng(seed, 10));
533 format!(
534 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
535 AppleWebKit/605.1.15 (KHTML, like Gecko) \
536 Version/{ver} Safari/605.1.15"
537 )
538 }
539 BrowserKind::Firefox => {
540 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
541 format!(
542 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:{ver}.0) \
543 Gecko/20100101 Firefox/{ver}.0"
544 )
545 }
546 };
547
548 let (webgl_vendor, webgl_renderer) = pick(MACOS_WEBGL_PROFILES, rng(seed, 7));
549 let fonts = MACOS_FONTS.iter().map(|s| (*s).to_string()).collect();
550
551 Self {
552 user_agent,
553 screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
554 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
555 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
556 platform: "MacIntel".to_string(),
557 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
558 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
559 webgl_vendor: Some(webgl_vendor.to_string()),
560 webgl_renderer: Some(webgl_renderer.to_string()),
561 canvas_noise: true,
562 fonts,
563 }
564 }
565
566 fn for_linux(seed: u64) -> Self {
567 let browser = BrowserKind::for_device(DeviceProfile::DesktopLinux, seed);
568 let user_agent = if browser == BrowserKind::Firefox {
569 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
570 format!(
571 "Mozilla/5.0 (X11; Linux x86_64; rv:{ver}.0) \
572 Gecko/20100101 Firefox/{ver}.0"
573 )
574 } else {
575 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
576 format!(
577 "Mozilla/5.0 (X11; Linux x86_64) \
578 AppleWebKit/537.36 (KHTML, like Gecko) \
579 Chrome/{ver}.0.0.0 Safari/537.36"
580 )
581 };
582
583 let fonts = LINUX_FONTS.iter().map(|s| (*s).to_string()).collect();
584
585 Self {
586 user_agent,
587 screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
588 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
589 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
590 platform: "Linux x86_64".to_string(),
591 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
592 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
593 webgl_vendor: Some("Mesa/X.org".to_string()),
594 webgl_renderer: Some("llvmpipe (LLVM 15.0.7, 256 bits)".to_string()),
595 canvas_noise: true,
596 fonts,
597 }
598 }
599
600 fn for_android(seed: u64) -> Self {
601 let browser = BrowserKind::for_device(DeviceProfile::MobileAndroid, seed);
602 let user_agent = if browser == BrowserKind::Firefox {
603 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
604 format!(
605 "Mozilla/5.0 (Android 14; Mobile; rv:{ver}.0) \
606 Gecko/20100101 Firefox/{ver}.0"
607 )
608 } else {
609 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
610 format!(
611 "Mozilla/5.0 (Linux; Android 14; Pixel 7) \
612 AppleWebKit/537.36 (KHTML, like Gecko) \
613 Chrome/{ver}.0.6099.144 Mobile Safari/537.36"
614 )
615 };
616
617 let (webgl_vendor, webgl_renderer) = pick(ANDROID_WEBGL_PROFILES, rng(seed, 6));
618 let fonts = MOBILE_ANDROID_FONTS
619 .iter()
620 .map(|s| (*s).to_string())
621 .collect();
622
623 Self {
624 user_agent,
625 screen_resolution: pick(MOBILE_ANDROID_RESOLUTIONS, rng(seed, 1)),
626 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
627 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
628 platform: "Linux armv8l".to_string(),
629 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
630 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
631 webgl_vendor: Some(webgl_vendor.to_string()),
632 webgl_renderer: Some(webgl_renderer.to_string()),
633 canvas_noise: true,
634 fonts,
635 }
636 }
637
638 fn for_ios(seed: u64) -> Self {
639 let safari_ver = pick(SAFARI_VERSIONS, rng(seed, 10));
640 let ios_ver = pick(IOS_OS_VERSIONS, rng(seed, 11));
641 let user_agent = format!(
642 "Mozilla/5.0 (iPhone; CPU iPhone OS {ios_ver} like Mac OS X) \
643 AppleWebKit/605.1.15 (KHTML, like Gecko) \
644 Version/{safari_ver} Mobile/15E148 Safari/604.1"
645 );
646
647 let (webgl_vendor, webgl_renderer) = pick(IOS_WEBGL_PROFILES, rng(seed, 6));
648 let fonts = MOBILE_IOS_FONTS.iter().map(|s| (*s).to_string()).collect();
649
650 Self {
651 user_agent,
652 screen_resolution: pick(MOBILE_IOS_RESOLUTIONS, rng(seed, 1)),
653 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
654 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
655 platform: "iPhone".to_string(),
656 hardware_concurrency: 6,
657 device_memory: 4,
658 webgl_vendor: Some(webgl_vendor.to_string()),
659 webgl_renderer: Some(webgl_renderer.to_string()),
660 canvas_noise: true,
661 fonts,
662 }
663 }
664
665 #[must_use]
685 pub fn injection_script(&self) -> String {
686 let mut parts = vec![
687 screen_script(self.screen_resolution),
688 timezone_script(&self.timezone),
689 language_script(&self.language, &self.user_agent),
690 hardware_script(self.hardware_concurrency, self.device_memory),
691 ];
692
693 if let (Some(vendor), Some(renderer)) = (&self.webgl_vendor, &self.webgl_renderer) {
694 parts.push(webgl_script(vendor, renderer));
695 }
696
697 if self.canvas_noise {
698 parts.push(canvas_noise_script());
699 }
700
701 parts.push(audio_fingerprint_script());
702 parts.push(connection_spoof_script());
703 parts.push(font_measurement_intercept_script());
704 parts.push(storage_estimate_spoof_script());
705 parts.push(battery_spoof_script());
706 parts.push(plugins_spoof_script());
707
708 format!("(function() {{\n{}\n}})();", parts.join("\n\n"))
709 }
710
711 #[must_use]
729 pub fn signature(&self) -> String {
730 fingerprint_signature(self)
731 }
732}
733
734#[must_use]
749pub fn fingerprint_signature(fp: &Fingerprint) -> String {
750 let mut buf = String::with_capacity(256);
751 let _ = write!(&mut buf, "ua={}", fp.user_agent);
752 let _ = write!(
753 &mut buf,
754 "\nscreen={}x{}",
755 fp.screen_resolution.0, fp.screen_resolution.1
756 );
757 let _ = write!(&mut buf, "\ntz={}", fp.timezone);
758 let _ = write!(&mut buf, "\nlang={}", fp.language);
759 let _ = write!(&mut buf, "\nplatform={}", fp.platform);
760 let _ = write!(
761 &mut buf,
762 "\nhw={} dm={}",
763 fp.hardware_concurrency, fp.device_memory
764 );
765 let _ = write!(
766 &mut buf,
767 "\nwebgl_vendor={}",
768 fp.webgl_vendor.as_deref().unwrap_or_default()
769 );
770 let _ = write!(
771 &mut buf,
772 "\nwebgl_renderer={}",
773 fp.webgl_renderer.as_deref().unwrap_or_default()
774 );
775 let _ = write!(&mut buf, "\ncanvas_noise={}", fp.canvas_noise);
776 let mut sorted_fonts: Vec<&str> = fp.fonts.iter().map(String::as_str).collect();
777 sorted_fonts.sort_unstable();
778 let _ = write!(&mut buf, "\nfonts={}", sorted_fonts.join(","));
779 signature_hash(&[buf.as_str()])
780}
781
782#[derive(Debug, Clone, Serialize, Deserialize)]
793pub struct FingerprintProfile {
794 pub name: String,
796
797 pub fingerprint: Fingerprint,
799}
800
801impl FingerprintProfile {
802 #[must_use]
813 pub fn new(name: String) -> Self {
814 Self {
815 name,
816 fingerprint: Fingerprint::random(),
817 }
818 }
819
820 #[must_use]
837 pub fn random_weighted(name: String) -> Self {
838 let seed = std::time::SystemTime::now()
839 .duration_since(std::time::UNIX_EPOCH)
840 .map_or(0x5a5a_5a5a_5a5a_5a5a, |d| {
841 d.as_secs() ^ u64::from(d.subsec_nanos())
842 });
843
844 let device = DeviceProfile::random_weighted(seed);
845 Self {
846 name,
847 fingerprint: Fingerprint::from_device_profile(device, seed),
848 }
849 }
850}
851
852#[must_use]
871pub fn inject_fingerprint(fingerprint: &Fingerprint) -> String {
872 fingerprint.injection_script()
873}
874
875#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
891pub enum DeviceProfile {
892 #[default]
894 DesktopWindows,
895 DesktopMac,
897 DesktopLinux,
899 MobileAndroid,
901 MobileIOS,
903}
904
905impl DeviceProfile {
906 #[must_use]
920 pub const fn random_weighted(seed: u64) -> Self {
921 let v = rng(seed, 97) % 100;
922 match v {
923 0..=69 => Self::DesktopWindows,
924 70..=89 => Self::DesktopMac,
925 _ => Self::DesktopLinux,
926 }
927 }
928
929 #[must_use]
940 pub const fn is_mobile(self) -> bool {
941 matches!(self, Self::MobileAndroid | Self::MobileIOS)
942 }
943}
944
945#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
959pub enum BrowserKind {
960 #[default]
962 Chrome,
963 Edge,
965 Safari,
967 Firefox,
969}
970
971impl BrowserKind {
972 #[must_use]
988 pub const fn for_device(device: DeviceProfile, seed: u64) -> Self {
989 match device {
990 DeviceProfile::MobileIOS => Self::Safari,
991 DeviceProfile::MobileAndroid => {
992 let v = rng(seed, 201) % 100;
993 if v < 90 { Self::Chrome } else { Self::Firefox }
994 }
995 DeviceProfile::DesktopMac => {
996 let v = rng(seed, 201) % 100;
997 match v {
998 0..=55 => Self::Chrome,
999 56..=91 => Self::Safari,
1000 _ => Self::Firefox,
1001 }
1002 }
1003 _ => {
1004 let v = rng(seed, 201) % 100;
1006 match v {
1007 0..=64 => Self::Chrome,
1008 65..=80 => Self::Edge,
1009 _ => Self::Firefox,
1010 }
1011 }
1012 }
1013 }
1014}
1015
1016fn screen_script((width, height): (u32, u32)) -> String {
1019 let avail_height = height.saturating_sub(40);
1021 format!(
1025 r" // Screen dimensions
1026 const _defineScreen = (prop, val) =>
1027 Object.defineProperty(screen, prop, {{ get: () => val, configurable: false }});
1028 _defineScreen('width', {width});
1029 _defineScreen('height', {height});
1030 _defineScreen('availWidth', {width});
1031 _defineScreen('availHeight', {avail_height});
1032 _defineScreen('availLeft', 0);
1033 _defineScreen('availTop', 0);
1034 _defineScreen('colorDepth', 24);
1035 _defineScreen('pixelDepth', 24);
1036 // outerWidth/outerHeight: headless Chrome may return 0; spoof to viewport size.
1037 try {{
1038 Object.defineProperty(window, 'outerWidth', {{ get: () => {width}, configurable: true }});
1039 Object.defineProperty(window, 'outerHeight', {{ get: () => {height}, configurable: true }});
1040 }} catch(_) {{}}"
1041 )
1042}
1043
1044fn timezone_script(timezone: &str) -> String {
1045 format!(
1046 r" // Timezone via Intl.DateTimeFormat
1047 const _origResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
1048 Intl.DateTimeFormat.prototype.resolvedOptions = function() {{
1049 const opts = _origResolvedOptions.apply(this, arguments);
1050 opts.timeZone = {timezone:?};
1051 return opts;
1052 }};"
1053 )
1054}
1055
1056fn language_script(language: &str, user_agent: &str) -> String {
1057 let primary = language.split('-').next().unwrap_or("en");
1059 format!(
1060 r" // Language + userAgent
1061 Object.defineProperty(navigator, 'language', {{ get: () => {language:?}, configurable: false }});
1062 Object.defineProperty(navigator, 'languages', {{ get: () => Object.freeze([{language:?}, {primary:?}]), configurable: false }});
1063 Object.defineProperty(navigator, 'userAgent', {{ get: () => {user_agent:?}, configurable: false }});"
1064 )
1065}
1066
1067fn hardware_script(concurrency: u32, memory: u32) -> String {
1068 format!(
1069 r" // Hardware concurrency + device memory
1070 Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {concurrency}, configurable: false }});
1071 Object.defineProperty(navigator, 'deviceMemory', {{ get: () => {memory}, configurable: false }});"
1072 )
1073}
1074
1075fn webgl_script(vendor: &str, renderer: &str) -> String {
1076 format!(
1077 r" // WebGL vendor + renderer
1078 (function() {{
1079 const _getContext = HTMLCanvasElement.prototype.getContext;
1080 HTMLCanvasElement.prototype.getContext = function(type, attrs) {{
1081 const ctx = _getContext.call(this, type, attrs);
1082 if (!ctx) return ctx;
1083 if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {{
1084 const _getParam = ctx.getParameter.bind(ctx);
1085 ctx.getParameter = function(param) {{
1086 if (param === 0x1F00) return {vendor:?}; // GL_VENDOR
1087 if (param === 0x1F01) return {renderer:?}; // GL_RENDERER
1088 return _getParam(param);
1089 }};
1090 }}
1091 return ctx;
1092 }};
1093 }})();"
1094 )
1095}
1096
1097fn canvas_noise_script() -> String {
1098 r" // Canvas noise: flip lowest bit of R/G/B channels to defeat pixel readback
1099 (function() {
1100 const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
1101 CanvasRenderingContext2D.prototype.getImageData = function() {
1102 const id = _getImageData.apply(this, arguments);
1103 const d = id.data;
1104 for (let i = 0; i < d.length; i += 4) {
1105 d[i] ^= 1;
1106 d[i + 1] ^= 1;
1107 d[i + 2] ^= 1;
1108 }
1109 return id;
1110 };
1111 })();"
1112 .to_string()
1113}
1114
1115fn audio_fingerprint_script() -> String {
1116 r" // Audio fingerprint defence: add sub-epsilon noise to frequency data
1117 (function() {
1118 if (typeof AnalyserNode === 'undefined') return;
1119 const _getFloatFreq = AnalyserNode.prototype.getFloatFrequencyData;
1120 AnalyserNode.prototype.getFloatFrequencyData = function(arr) {
1121 _getFloatFreq.apply(this, arguments);
1122 for (let i = 0; i < arr.length; i++) {
1123 arr[i] += (Math.random() - 0.5) * 1e-7;
1124 }
1125 };
1126 })();"
1127 .to_string()
1128}
1129
1130fn connection_spoof_script() -> String {
1134 r" // NetworkInformation API spoof (navigator.connection)
1137 (function() {
1138 const _seed = Math.floor(performance.timeOrigin % 997);
1139 const conn = {
1140 rtt: 50 + _seed % 100,
1141 downlink: 5 + _seed % 15,
1142 effectiveType: '4g',
1143 type: 'wifi',
1144 saveData: false,
1145 onchange: null,
1146 ontypechange: null,
1147 addEventListener: function() {},
1148 removeEventListener: function() {},
1149 dispatchEvent: function() { return true; },
1150 };
1151 try {
1152 Object.defineProperty(navigator, 'connection', {
1153 get: () => conn,
1154 enumerable: true,
1155 configurable: false,
1156 });
1157 } catch (_) {}
1158 })();"
1159 .to_string()
1160}
1161
1162fn font_measurement_intercept_script() -> String {
1173 r" // getBoundingClientRect font-probe intercept (Turnstile Layer 1)
1174 (function() {
1175 const _origGBCR = Element.prototype.getBoundingClientRect;
1176 const _seed = Math.floor(performance.timeOrigin % 9973);
1177 function _jitter(base, range) {
1178 return base + ((_seed * 1103515245 + 12345) & 0x7fffffff) % range;
1179 }
1180 Element.prototype.getBoundingClientRect = function() {
1181 const rect = _origGBCR.call(this);
1182 // Only intercept zero-size rects on hidden probe elements (the font-
1183 // measurement pattern: position absolute/fixed, visibility hidden).
1184 if (rect.width === 0 && rect.height === 0) {
1185 const st = window.getComputedStyle(this);
1186 const vis = st.getPropertyValue('visibility');
1187 const pos = st.getPropertyValue('position');
1188 const ariaHidden = this.getAttribute('aria-hidden');
1189 if ((vis === 'hidden' || ariaHidden === 'true') &&
1190 (pos === 'absolute' || pos === 'fixed')) {
1191 const w = _jitter(10, 8);
1192 const h = _jitter(14, 4);
1193 return new DOMRect(0, 0, w, h);
1194 }
1195 }
1196 return rect;
1197 };
1198 })();"
1199 .to_string()
1200}
1201
1202fn storage_estimate_spoof_script() -> String {
1208 r" // navigator.storage.estimate() spoof (Turnstile Layer 1 — storage)
1209 (function() {
1210 if (!navigator.storage || typeof navigator.storage.estimate !== 'function') return;
1211 const _origEstimate = navigator.storage.estimate.bind(navigator.storage);
1212 const _seed = Math.floor(performance.timeOrigin % 9973);
1213 // Realistic Chrome profile: ~250 GB quota, small stable usage.
1214 const quota = (240 + _seed % 20) * 1073741824;
1215 const usage = (5 + _seed % 10) * 1048576;
1216 navigator.storage.estimate = function() {
1217 return _origEstimate().then(function(result) {
1218 return Object.assign({}, result, {
1219 quota: quota,
1220 usage: usage
1221 });
1222 });
1223 };
1224 })();"
1225 .to_string()
1226}
1227
1228fn plugins_spoof_script() -> String {
1237 r" // navigator.plugins / mimeTypes — empty array = instant headless flag
1238 (function() {
1239 // Build minimal objects that survive instanceof checks.
1240 var mime0 = { type: 'application/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1241 var mime1 = { type: 'text/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1242 var pdfPlugin = {
1243 name: 'PDF Viewer',
1244 description: 'Portable Document Format',
1245 filename: 'internal-pdf-viewer',
1246 length: 2,
1247 0: mime0, 1: mime1,
1248 item: function(i) { return [mime0, mime1][i] || null; },
1249 namedItem: function(n) {
1250 if (n === 'application/pdf') return mime0;
1251 if (n === 'text/pdf') return mime1;
1252 return null;
1253 },
1254 };
1255 mime0.enabledPlugin = pdfPlugin;
1256 mime1.enabledPlugin = pdfPlugin;
1257
1258 var fakePlugins = {
1259 length: 1,
1260 0: pdfPlugin,
1261 item: function(i) { return i === 0 ? pdfPlugin : null; },
1262 namedItem: function(n) { return n === 'PDF Viewer' ? pdfPlugin : null; },
1263 refresh: function() {},
1264 };
1265 var fakeMimes = {
1266 length: 2,
1267 0: mime0, 1: mime1,
1268 item: function(i) { return [mime0, mime1][i] || null; },
1269 namedItem: function(n) {
1270 if (n === 'application/pdf') return mime0;
1271 if (n === 'text/pdf') return mime1;
1272 return null;
1273 },
1274 };
1275
1276 try {
1277 Object.defineProperty(navigator, 'plugins', { get: function() { return fakePlugins; }, configurable: false });
1278 Object.defineProperty(navigator, 'mimeTypes', { get: function() { return fakeMimes; }, configurable: false });
1279 } catch(_) {}
1280 })();"
1281 .to_string()
1282}
1283
1284fn battery_spoof_script() -> String {
1285 r" // Battery API normalization (navigator.getBattery)
1286 (function() {
1287 if (typeof navigator.getBattery !== 'function') return;
1288 const _seed = Math.floor(performance.timeOrigin % 997);
1289 const battery = {
1290 charging: false,
1291 chargingTime: Infinity,
1292 dischargingTime: 3600 + _seed * 7,
1293 level: 0.65 + (_seed % 30) / 100,
1294 onchargingchange: null,
1295 onchargingtimechange: null,
1296 ondischargingtimechange: null,
1297 onlevelchange: null,
1298 addEventListener: function() {},
1299 removeEventListener: function() {},
1300 dispatchEvent: function() { return true; },
1301 };
1302 navigator.getBattery = function() {
1303 return Promise.resolve(battery);
1304 };
1305 })();"
1306 .to_string()
1307}
1308
1309#[cfg(test)]
1312mod tests {
1313 use super::*;
1314
1315 #[test]
1316 fn random_fingerprint_has_valid_ranges() {
1317 let fp = Fingerprint::random();
1318 let (w, h) = fp.screen_resolution;
1319 assert!(
1320 (1280..=3840).contains(&w),
1321 "width {w} out of expected range"
1322 );
1323 assert!(
1324 (768..=2160).contains(&h),
1325 "height {h} out of expected range"
1326 );
1327 assert!(
1328 HARDWARE_CONCURRENCY.contains(&fp.hardware_concurrency),
1329 "hardware_concurrency {} not in pool",
1330 fp.hardware_concurrency
1331 );
1332 assert!(
1333 DEVICE_MEMORY.contains(&fp.device_memory),
1334 "device_memory {} not in pool",
1335 fp.device_memory
1336 );
1337 assert!(
1338 TIMEZONES.contains(&fp.timezone.as_str()),
1339 "timezone {} not in pool",
1340 fp.timezone
1341 );
1342 assert!(
1343 LANGUAGES.contains(&fp.language.as_str()),
1344 "language {} not in pool",
1345 fp.language
1346 );
1347 }
1348
1349 #[test]
1350 fn random_generates_different_values_over_time() {
1351 let fp1 = Fingerprint::random();
1354 let fp2 = Fingerprint::random();
1355 assert!(!fp1.user_agent.is_empty());
1357 assert!(!fp2.user_agent.is_empty());
1358 }
1359
1360 #[test]
1361 fn signature_is_stable_and_field_sensitive() {
1362 let a = Fingerprint::default().signature();
1363 let b = Fingerprint::default().signature();
1364 assert_eq!(a, b, "identical fingerprints must produce equal signatures");
1365 assert!(a.starts_with("fnv64:"));
1366
1367 let changed = Fingerprint {
1369 screen_resolution: (1024, 768),
1370 ..Fingerprint::default()
1371 };
1372 let c = changed.signature();
1373 assert_ne!(a, c, "changing screen resolution must rotate the signature");
1374 }
1375
1376 #[test]
1377 fn injection_script_contains_screen_dimensions() {
1378 let fp = Fingerprint {
1379 screen_resolution: (2560, 1440),
1380 ..Fingerprint::default()
1381 };
1382 let script = fp.injection_script();
1383 assert!(script.contains("2560"), "missing width in script");
1384 assert!(script.contains("1440"), "missing height in script");
1385 }
1386
1387 #[test]
1388 fn injection_script_contains_timezone() {
1389 let fp = Fingerprint {
1390 timezone: "Europe/Berlin".to_string(),
1391 ..Fingerprint::default()
1392 };
1393 let script = fp.injection_script();
1394 assert!(script.contains("Europe/Berlin"), "timezone missing");
1395 }
1396
1397 #[test]
1398 fn injection_script_contains_canvas_noise_when_enabled() {
1399 let fp = Fingerprint {
1400 canvas_noise: true,
1401 ..Fingerprint::default()
1402 };
1403 let script = fp.injection_script();
1404 assert!(
1405 script.contains("getImageData"),
1406 "canvas noise block missing"
1407 );
1408 }
1409
1410 #[test]
1411 fn injection_script_omits_canvas_noise_when_disabled() {
1412 let fp = Fingerprint {
1413 canvas_noise: false,
1414 ..Fingerprint::default()
1415 };
1416 let script = fp.injection_script();
1417 assert!(
1418 !script.contains("getImageData"),
1419 "canvas noise should be absent"
1420 );
1421 }
1422
1423 #[test]
1424 fn injection_script_contains_webgl_vendor() {
1425 let fp = Fingerprint {
1426 webgl_vendor: Some("TestVendor".to_string()),
1427 webgl_renderer: Some("TestRenderer".to_string()),
1428 canvas_noise: false,
1429 ..Fingerprint::default()
1430 };
1431 let script = fp.injection_script();
1432 assert!(script.contains("TestVendor"), "WebGL vendor missing");
1433 assert!(script.contains("TestRenderer"), "WebGL renderer missing");
1434 }
1435
1436 #[test]
1437 fn inject_fingerprint_fn_equals_method() {
1438 let fp = Fingerprint::default();
1439 assert_eq!(inject_fingerprint(&fp), fp.injection_script());
1440 }
1441
1442 #[test]
1443 fn from_profile_returns_profile_fingerprint() {
1444 let profile = FingerprintProfile::new("test".to_string());
1445 let fp = Fingerprint::from_profile(&profile);
1446 assert_eq!(fp.user_agent, profile.fingerprint.user_agent);
1447 }
1448
1449 #[test]
1450 fn script_is_wrapped_in_iife() {
1451 let script = Fingerprint::default().injection_script();
1452 assert!(script.starts_with("(function()"), "must start with IIFE");
1453 assert!(script.ends_with("})();"), "must end with IIFE call");
1454 }
1455
1456 #[test]
1457 fn rng_produces_distinct_values_for_different_steps() {
1458 let seed = 0xdead_beef_cafe_babe_u64;
1459 let v1 = rng(seed, 1);
1460 let v2 = rng(seed, 2);
1461 let v3 = rng(seed, 3);
1462 assert_ne!(v1, v2);
1463 assert_ne!(v2, v3);
1464 }
1465
1466 #[test]
1469 fn device_profile_windows_is_consistent() {
1470 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 42);
1471 assert_eq!(fp.platform, "Win32");
1472 assert!(fp.user_agent.contains("Windows NT"), "UA must be Windows");
1473 assert!(!fp.fonts.is_empty(), "Windows profile must have fonts");
1474 assert!(
1475 fp.validate_consistency().is_empty(),
1476 "must pass consistency"
1477 );
1478 }
1479
1480 #[test]
1481 fn device_profile_mac_is_consistent() {
1482 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
1483 assert_eq!(fp.platform, "MacIntel");
1484 assert!(
1485 fp.user_agent.contains("Mac OS X"),
1486 "UA must be macOS: {}",
1487 fp.user_agent
1488 );
1489 assert!(!fp.fonts.is_empty(), "Mac profile must have fonts");
1490 assert!(
1491 fp.validate_consistency().is_empty(),
1492 "must pass consistency"
1493 );
1494 }
1495
1496 #[test]
1497 fn device_profile_linux_is_consistent() {
1498 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopLinux, 42);
1499 assert_eq!(fp.platform, "Linux x86_64");
1500 assert!(fp.user_agent.contains("Linux"), "UA must be Linux");
1501 assert!(!fp.fonts.is_empty(), "Linux profile must have fonts");
1502 assert!(
1503 fp.validate_consistency().is_empty(),
1504 "must pass consistency"
1505 );
1506 }
1507
1508 #[test]
1509 fn device_profile_android_is_mobile() {
1510 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileAndroid, 42);
1511 assert!(
1512 fp.platform.starts_with("Linux"),
1513 "Android platform should be Linux-based"
1514 );
1515 assert!(
1516 fp.user_agent.contains("Android") || fp.user_agent.contains("Firefox"),
1517 "Android UA mismatch: {}",
1518 fp.user_agent
1519 );
1520 assert!(!fp.fonts.is_empty());
1521 assert!(DeviceProfile::MobileAndroid.is_mobile());
1522 }
1523
1524 #[test]
1525 fn device_profile_ios_is_mobile() {
1526 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileIOS, 42);
1527 assert_eq!(fp.platform, "iPhone");
1528 assert!(
1529 fp.user_agent.contains("iPhone"),
1530 "iOS UA must contain iPhone"
1531 );
1532 assert!(!fp.fonts.is_empty());
1533 assert!(DeviceProfile::MobileIOS.is_mobile());
1534 }
1535
1536 #[test]
1537 fn desktop_profiles_are_not_mobile() {
1538 assert!(!DeviceProfile::DesktopWindows.is_mobile());
1539 assert!(!DeviceProfile::DesktopMac.is_mobile());
1540 assert!(!DeviceProfile::DesktopLinux.is_mobile());
1541 }
1542
1543 #[test]
1544 fn browser_kind_ios_always_safari() {
1545 for seed in [0u64, 1, 42, 999, u64::MAX] {
1546 assert_eq!(
1547 BrowserKind::for_device(DeviceProfile::MobileIOS, seed),
1548 BrowserKind::Safari,
1549 "iOS must always return Safari (seed={seed})"
1550 );
1551 }
1552 }
1553
1554 #[test]
1555 fn device_profile_random_weighted_distribution() {
1556 let windows_count = (0u64..1000)
1558 .filter(|&i| {
1559 DeviceProfile::random_weighted(i * 13 + 7) == DeviceProfile::DesktopWindows
1560 })
1561 .count();
1562 assert!(
1563 windows_count >= 500,
1564 "Expected ≥50% Windows, got {windows_count}/1000"
1565 );
1566 }
1567
1568 #[test]
1569 fn validate_consistency_catches_platform_ua_mismatch() {
1570 let fp = Fingerprint {
1571 platform: "Win32".to_string(),
1572 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
1573 AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
1574 .to_string(),
1575 ..Fingerprint::default()
1576 };
1577 let issues = fp.validate_consistency();
1578 assert!(!issues.is_empty(), "should detect Win32+Mac UA mismatch");
1579 }
1580
1581 #[test]
1582 fn validate_consistency_catches_platform_font_mismatch() {
1583 let fp = Fingerprint {
1584 platform: "MacIntel".to_string(),
1585 fonts: vec!["Segoe UI".to_string(), "Calibri".to_string()],
1586 ..Fingerprint::default()
1587 };
1588 let issues = fp.validate_consistency();
1589 assert!(
1590 !issues.is_empty(),
1591 "should detect MacIntel + Windows fonts mismatch"
1592 );
1593 }
1594
1595 #[test]
1596 fn validate_consistency_passes_for_default() {
1597 let fp = Fingerprint::default();
1598 assert!(fp.validate_consistency().is_empty());
1599 }
1600
1601 #[test]
1602 fn fingerprint_profile_random_weighted_has_fonts() {
1603 let profile = FingerprintProfile::random_weighted("sess-1".to_string());
1604 assert_eq!(profile.name, "sess-1");
1605 assert!(!profile.fingerprint.fonts.is_empty());
1606 assert!(profile.fingerprint.validate_consistency().is_empty());
1607 }
1608
1609 #[test]
1610 fn from_device_profile_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
1611 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 123);
1612 let json = serde_json::to_string(&fp)?;
1613 let back: Fingerprint = serde_json::from_str(&json)?;
1614 assert_eq!(back.platform, fp.platform);
1615 assert_eq!(back.fonts, fp.fonts);
1616 Ok(())
1617 }
1618
1619 proptest::proptest! {
1622 #[test]
1624 fn prop_seeded_fingerprint_always_consistent(seed in 0u64..10_000) {
1625 let profile = DeviceProfile::random_weighted(seed);
1626 let fp = Fingerprint::from_device_profile(profile, seed);
1627 let issues = fp.validate_consistency();
1628 proptest::prop_assert!(
1629 issues.is_empty(),
1630 "validate_consistency() failed for seed {seed}: {issues:?}"
1631 );
1632 }
1633
1634 #[test]
1636 fn prop_hardware_concurrency_is_sensible(_seed in 0u64..10_000) {
1637 let fp = Fingerprint::random();
1638 proptest::prop_assert!(
1639 fp.hardware_concurrency >= 1 && fp.hardware_concurrency <= 32,
1640 "hardware_concurrency {} out of [1,32]", fp.hardware_concurrency
1641 );
1642 }
1643
1644 #[test]
1646 fn prop_device_memory_is_valid_value(_seed in 0u64..10_000) {
1647 let fp = Fingerprint::random();
1648 let valid: &[u32] = &[4, 8, 16];
1649 proptest::prop_assert!(
1650 valid.contains(&fp.device_memory),
1651 "device_memory {} is not a valid value", fp.device_memory
1652 );
1653 }
1654
1655 #[test]
1657 fn prop_screen_dimensions_are_plausible(_seed in 0u64..10_000) {
1658 let fp = Fingerprint::random();
1659 let (w, h) = fp.screen_resolution;
1660 proptest::prop_assert!((320..=7680).contains(&w));
1661 proptest::prop_assert!((240..=4320).contains(&h));
1662 }
1663
1664 #[test]
1666 fn prop_fingerprint_profile_passes_consistency(name in "[a-z][a-z0-9]{0,31}") {
1667 let profile = FingerprintProfile::random_weighted(name.clone());
1668 let issues = profile.fingerprint.validate_consistency();
1669 proptest::prop_assert!(
1670 issues.is_empty(),
1671 "FingerprintProfile for '{name}' has issues: {issues:?}"
1672 );
1673 }
1674
1675 #[test]
1677 fn prop_injection_script_non_empty(_seed in 0u64..10_000) {
1678 let fp = Fingerprint::random();
1679 let script = inject_fingerprint(&fp);
1680 proptest::prop_assert!(!script.is_empty());
1681 proptest::prop_assert!(script.contains("navigator"));
1682 }
1683 }
1684}