1use serde::{Deserialize, Serialize};
19
20use crate::{noise::NoiseSeed, webgl_noise::WebGlProfile};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum Os {
30 Windows,
31 MacOs,
32 Linux,
33 Android,
34 Ios,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct PlatformProfile {
48 pub os: Os,
50 pub os_version: String,
52 pub platform_string: String,
54 pub max_touch_points: u8,
56 pub keyboard_layout: String,
58}
59
60impl PlatformProfile {
61 #[must_use]
63 pub fn windows() -> Self {
64 Self {
65 os: Os::Windows,
66 os_version: "10.0.0".into(),
67 platform_string: "Win32".into(),
68 max_touch_points: 0,
69 keyboard_layout: "en-US".into(),
70 }
71 }
72
73 #[must_use]
75 pub fn macos() -> Self {
76 Self {
77 os: Os::MacOs,
78 os_version: "14.0.0".into(),
79 platform_string: "MacIntel".into(),
80 max_touch_points: 0,
81 keyboard_layout: "en-US".into(),
82 }
83 }
84
85 #[must_use]
87 pub fn linux() -> Self {
88 Self {
89 os: Os::Linux,
90 os_version: "5.15.0".into(),
91 platform_string: "Linux x86_64".into(),
92 max_touch_points: 0,
93 keyboard_layout: "en-US".into(),
94 }
95 }
96
97 #[must_use]
99 pub fn android() -> Self {
100 Self {
101 os: Os::Android,
102 os_version: "13".into(),
103 platform_string: "Linux armv81".into(),
104 max_touch_points: 5,
105 keyboard_layout: "en-US".into(),
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "lowercase")]
113pub enum BrowserKind {
114 Chrome,
115 Edge,
116 Firefox,
117 Safari,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct BrowserProfile {
131 pub kind: BrowserKind,
133 pub version: u32,
135 pub user_agent: String,
137 pub sec_ch_ua: String,
139 pub sec_ch_ua_platform: String,
141 pub sec_ch_ua_mobile: String,
143}
144
145impl BrowserProfile {
146 #[must_use]
148 pub fn chrome_136_windows() -> Self {
149 Self {
150 kind: BrowserKind::Chrome,
151 version: 136,
152 user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36".into(),
153 sec_ch_ua: r#""Chromium";v="136", "Google Chrome";v="136", "Not-A.Brand";v="99""#.into(),
154 sec_ch_ua_platform: "\"Windows\"".into(),
155 sec_ch_ua_mobile: "?0".into(),
156 }
157 }
158
159 #[must_use]
161 pub fn chrome_136_macos() -> Self {
162 Self {
163 kind: BrowserKind::Chrome,
164 version: 136,
165 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36".into(),
166 sec_ch_ua: r#""Chromium";v="136", "Google Chrome";v="136", "Not-A.Brand";v="99""#.into(),
167 sec_ch_ua_platform: "\"macOS\"".into(),
168 sec_ch_ua_mobile: "?0".into(),
169 }
170 }
171
172 #[must_use]
174 pub fn chrome_136_linux() -> Self {
175 Self {
176 kind: BrowserKind::Chrome,
177 version: 136,
178 user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36".into(),
179 sec_ch_ua: r#""Chromium";v="136", "Google Chrome";v="136", "Not-A.Brand";v="99""#.into(),
180 sec_ch_ua_platform: "\"Linux\"".into(),
181 sec_ch_ua_mobile: "?0".into(),
182 }
183 }
184
185 #[must_use]
187 pub fn edge_136_windows() -> Self {
188 Self {
189 kind: BrowserKind::Edge,
190 version: 136,
191 user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0".into(),
192 sec_ch_ua: r#""Chromium";v="136", "Microsoft Edge";v="136", "Not-A.Brand";v="99""#.into(),
193 sec_ch_ua_platform: "\"Windows\"".into(),
194 sec_ch_ua_mobile: "?0".into(),
195 }
196 }
197
198 #[must_use]
200 pub fn chrome_136_android() -> Self {
201 Self {
202 kind: BrowserKind::Chrome,
203 version: 136,
204 user_agent: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36".into(),
205 sec_ch_ua: r#""Chromium";v="136", "Google Chrome";v="136", "Not-A.Brand";v="99""#.into(),
206 sec_ch_ua_platform: "\"Android\"".into(),
207 sec_ch_ua_mobile: "?1".into(),
208 }
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct ScreenProfile {
224 pub width: u32,
226 pub height: u32,
228 pub avail_width: u32,
230 pub avail_height: u32,
232 pub dpr: f64,
234 pub color_depth: u8,
236 pub orientation: String,
238}
239
240impl ScreenProfile {
241 #[must_use]
243 pub fn fhd_desktop() -> Self {
244 Self {
245 width: 1920,
246 height: 1080,
247 avail_width: 1920,
248 avail_height: 1040,
249 dpr: 1.0,
250 color_depth: 24,
251 orientation: "landscape-primary".into(),
252 }
253 }
254
255 #[must_use]
257 pub fn qhd_desktop() -> Self {
258 Self {
259 width: 2560,
260 height: 1440,
261 avail_width: 2560,
262 avail_height: 1400,
263 dpr: 1.0,
264 color_depth: 24,
265 orientation: "landscape-primary".into(),
266 }
267 }
268
269 #[must_use]
271 pub fn macbook_pro_14() -> Self {
272 Self {
273 width: 1512,
274 height: 982,
275 avail_width: 1512,
276 avail_height: 957,
277 dpr: 2.0,
278 color_depth: 24,
279 orientation: "landscape-primary".into(),
280 }
281 }
282
283 #[must_use]
285 pub fn pixel_7() -> Self {
286 Self {
287 width: 393,
288 height: 851,
289 avail_width: 393,
290 avail_height: 851,
291 dpr: 2.75,
292 color_depth: 24,
293 orientation: "portrait-primary".into(),
294 }
295 }
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct HardwareProfile {
309 pub cores: u32,
311 pub memory_gb: u32,
313 pub gpu_vendor: String,
315 pub gpu_renderer: String,
317}
318
319impl HardwareProfile {
320 #[must_use]
322 pub fn desktop_gaming() -> Self {
323 Self {
324 cores: 8,
325 memory_gb: 8,
326 gpu_vendor: "NVIDIA".into(),
327 gpu_renderer: "NVIDIA GeForce RTX 3060".into(),
328 }
329 }
330
331 #[must_use]
333 pub fn desktop_gtx1660() -> Self {
334 Self {
335 cores: 8,
336 memory_gb: 8,
337 gpu_vendor: "NVIDIA".into(),
338 gpu_renderer: "NVIDIA GeForce GTX 1660 Ti".into(),
339 }
340 }
341
342 #[must_use]
344 pub fn apple_m1() -> Self {
345 Self {
346 cores: 8,
347 memory_gb: 8,
348 gpu_vendor: "Apple".into(),
349 gpu_renderer: "Apple M1".into(),
350 }
351 }
352
353 #[must_use]
355 pub fn intel_uhd_630() -> Self {
356 Self {
357 cores: 4,
358 memory_gb: 4,
359 gpu_vendor: "Intel".into(),
360 gpu_renderer: "Intel UHD Graphics 630".into(),
361 }
362 }
363
364 #[must_use]
366 pub fn mobile_snapdragon() -> Self {
367 Self {
368 cores: 8,
369 memory_gb: 4,
370 gpu_vendor: "Qualcomm".into(),
371 gpu_renderer: "Adreno (TM) 730".into(),
372 }
373 }
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct NetworkProfile {
387 pub rtt: u32,
389 pub downlink: f64,
391 pub effective_type: String,
393 pub save_data: bool,
395}
396
397impl NetworkProfile {
398 #[must_use]
400 pub fn fast_wifi() -> Self {
401 Self {
402 rtt: 50,
403 downlink: 10.0,
404 effective_type: "4g".into(),
405 save_data: false,
406 }
407 }
408
409 #[must_use]
411 pub fn mobile_4g() -> Self {
412 Self {
413 rtt: 100,
414 downlink: 5.0,
415 effective_type: "4g".into(),
416 save_data: false,
417 }
418 }
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct FingerprintProfile {
443 pub name: String,
445 pub platform: PlatformProfile,
447 pub browser: BrowserProfile,
449 pub screen: ScreenProfile,
451 pub hardware: HardwareProfile,
453 pub webgl: WebGlProfile,
455 pub network: NetworkProfile,
457 pub noise_seed: NoiseSeed,
459}
460
461impl FingerprintProfile {
462 #[must_use]
474 pub fn windows_chrome_136_rtx3060() -> Self {
475 Self {
476 name: "windows-chrome-136-rtx3060".into(),
477 platform: PlatformProfile::windows(),
478 browser: BrowserProfile::chrome_136_windows(),
479 screen: ScreenProfile::fhd_desktop(),
480 hardware: HardwareProfile::desktop_gaming(),
481 webgl: WebGlProfile::nvidia_rtx_3060(),
482 network: NetworkProfile::fast_wifi(),
483 noise_seed: NoiseSeed::from(0x3c1b_6a2d_f5e0_9874_u64),
484 }
485 }
486
487 #[must_use]
497 pub fn windows_chrome_136_gtx1660() -> Self {
498 Self {
499 name: "windows-chrome-136-gtx1660".into(),
500 platform: PlatformProfile::windows(),
501 browser: BrowserProfile::chrome_136_windows(),
502 screen: ScreenProfile::qhd_desktop(),
503 hardware: HardwareProfile::desktop_gtx1660(),
504 webgl: WebGlProfile::nvidia_gtx_1660(),
505 network: NetworkProfile::fast_wifi(),
506 noise_seed: NoiseSeed::from(0x7a8e_2c3d_b4f1_5609_u64),
507 }
508 }
509
510 #[must_use]
520 pub fn macos_chrome_136_m1() -> Self {
521 Self {
522 name: "macos-chrome-136-m1".into(),
523 platform: PlatformProfile::macos(),
524 browser: BrowserProfile::chrome_136_macos(),
525 screen: ScreenProfile::macbook_pro_14(),
526 hardware: HardwareProfile::apple_m1(),
527 webgl: WebGlProfile::intel_uhd_630(), network: NetworkProfile::fast_wifi(),
529 noise_seed: NoiseSeed::from(0x1d2e_3f4a_5b6c_7d8e_u64),
530 }
531 }
532
533 #[must_use]
543 pub fn linux_chrome_136_intel() -> Self {
544 Self {
545 name: "linux-chrome-136-intel".into(),
546 platform: PlatformProfile::linux(),
547 browser: BrowserProfile::chrome_136_linux(),
548 screen: ScreenProfile::fhd_desktop(),
549 hardware: HardwareProfile::intel_uhd_630(),
550 webgl: WebGlProfile::intel_uhd_630(),
551 network: NetworkProfile::fast_wifi(),
552 noise_seed: NoiseSeed::from(0x9f8e_7d6c_5b4a_3021_u64),
553 }
554 }
555
556 #[must_use]
566 pub fn windows_edge_136_rtx3060() -> Self {
567 Self {
568 name: "windows-edge-136-rtx3060".into(),
569 platform: PlatformProfile::windows(),
570 browser: BrowserProfile::edge_136_windows(),
571 screen: ScreenProfile::fhd_desktop(),
572 hardware: HardwareProfile::desktop_gaming(),
573 webgl: WebGlProfile::nvidia_rtx_3060(),
574 network: NetworkProfile::fast_wifi(),
575 noise_seed: NoiseSeed::from(0x2b4d_6f80_a2c4_e6f8_u64),
576 }
577 }
578
579 #[must_use]
589 pub fn android_chrome_136_pixel() -> Self {
590 Self {
591 name: "android-chrome-136-pixel".into(),
592 platform: PlatformProfile::android(),
593 browser: BrowserProfile::chrome_136_android(),
594 screen: ScreenProfile::pixel_7(),
595 hardware: HardwareProfile::mobile_snapdragon(),
596 webgl: WebGlProfile::amd_rx_6700(), network: NetworkProfile::mobile_4g(),
598 noise_seed: NoiseSeed::from(0x4c8a_0f1e_2d3b_5a69_u64),
599 }
600 }
601
602 #[must_use]
616 pub fn random_weighted() -> Self {
617 use std::time::{SystemTime, UNIX_EPOCH};
618 let seed = SystemTime::now()
619 .duration_since(UNIX_EPOCH)
620 .map_or(0xdead_beef_1234_5678, |d| {
621 d.as_secs() ^ u64::from(d.subsec_nanos())
622 });
623 let v = seed
625 .wrapping_add(0x9e37_79b9_7f4a_7c15_u64)
626 .wrapping_mul(0xbf58_476d_1ce4_e5b9);
627 let pick = v % 100;
628 match pick {
629 0..=64 => Self::windows_chrome_136_rtx3060(), 65..=84 => Self::macos_chrome_136_m1(), 85..=89 => Self::linux_chrome_136_intel(), _ => Self::android_chrome_136_pixel(), }
634 }
635
636 pub fn validate(&self) -> Result<(), Vec<String>> {
659 let mut errors: Vec<String> = Vec::new();
660
661 if matches!(self.platform.os, Os::Android | Os::Ios) && self.platform.max_touch_points == 0
663 {
664 errors.push(format!(
665 "Mobile OS {:?} must have max_touch_points > 0 (got 0)",
666 self.platform.os
667 ));
668 }
669
670 if matches!(self.platform.os, Os::Windows | Os::MacOs | Os::Linux)
672 && self.platform.max_touch_points > 0
673 && self.platform.max_touch_points < 5
674 {
675 errors.push(format!(
678 "Desktop OS {:?} has suspicious max_touch_points {} (expected 0 or ≥5 for touch-screen)",
679 self.platform.os, self.platform.max_touch_points
680 ));
681 }
682
683 if self.hardware.cores == 0 || self.hardware.cores > 128 {
685 errors.push(format!(
686 "hardwareConcurrency {} is out of range [1, 128]",
687 self.hardware.cores
688 ));
689 }
690
691 let mem = self.hardware.memory_gb;
693 if mem == 0 || !mem.is_power_of_two() || mem > 32 {
694 errors.push(format!(
695 "deviceMemory {mem} GB is not a power-of-two in [1, 32]"
696 ));
697 }
698
699 if self.screen.width == 0 || self.screen.height == 0 {
701 errors.push("screen width/height must be non-zero".into());
702 }
703
704 if self.screen.avail_width > self.screen.width
706 || self.screen.avail_height > self.screen.height
707 {
708 errors.push(format!(
709 "avail_width/avail_height ({}/{}) must be ≤ screen size ({}/{})",
710 self.screen.avail_width,
711 self.screen.avail_height,
712 self.screen.width,
713 self.screen.height
714 ));
715 }
716
717 if self.screen.dpr <= 0.0 {
719 errors.push(format!("dpr {} must be > 0", self.screen.dpr));
720 }
721
722 if self.platform.os == Os::Windows && self.platform.platform_string.contains("Mac") {
724 errors.push(format!(
725 "Windows OS has macOS-indicating platform_string '{}'",
726 self.platform.platform_string
727 ));
728 }
729
730 if self.platform.os == Os::MacOs && self.platform.platform_string.contains("Win") {
732 errors.push(format!(
733 "macOS OS has Windows-indicating platform_string '{}'",
734 self.platform.platform_string
735 ));
736 }
737
738 let is_mobile_ua = self.browser.sec_ch_ua_mobile == "?1";
740 let is_mobile_os = matches!(self.platform.os, Os::Android | Os::Ios);
741 if is_mobile_ua != is_mobile_os {
742 errors.push(format!(
743 "sec_ch_ua_mobile '{}' inconsistent with OS {:?}",
744 self.browser.sec_ch_ua_mobile, self.platform.os
745 ));
746 }
747
748 let (vpw, vph) = self.webgl.max_viewport_dims;
750 if self.webgl.max_texture_size > vpw || self.webgl.max_texture_size > vph {
751 errors.push(format!(
752 "WebGL max_texture_size {} exceeds max_viewport_dims ({},{})",
753 self.webgl.max_texture_size, vpw, vph
754 ));
755 }
756
757 if errors.is_empty() {
758 Ok(())
759 } else {
760 Err(errors)
761 }
762 }
763}
764
765#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn all_builtin_profiles_are_valid() {
775 let profiles = [
776 FingerprintProfile::windows_chrome_136_rtx3060(),
777 FingerprintProfile::windows_chrome_136_gtx1660(),
778 FingerprintProfile::macos_chrome_136_m1(),
779 FingerprintProfile::linux_chrome_136_intel(),
780 FingerprintProfile::windows_edge_136_rtx3060(),
781 FingerprintProfile::android_chrome_136_pixel(),
782 ];
783 for p in &profiles {
784 let validation = p.validate();
785 assert!(
786 validation.is_ok(),
787 "profile '{}' failed validation: {validation:?}",
788 p.name
789 );
790 }
791 }
792
793 #[test]
794 fn inconsistent_profile_fails_validation() {
795 let mut p = FingerprintProfile::windows_chrome_136_rtx3060();
797 p.platform.platform_string = "MacIntel".into();
798 let result = p.validate();
799 assert!(result.is_err(), "expected validation failure");
800 let Err(errs) = result else {
801 return;
802 };
803 assert!(
804 errs.iter().any(|e| e.contains("macOS-indicating")),
805 "expected cross-OS platform_string error, got: {errs:?}"
806 );
807 }
808
809 #[test]
810 fn inconsistent_mobile_fails_validation() {
811 let mut p = FingerprintProfile::android_chrome_136_pixel();
812 p.platform.max_touch_points = 0;
813 assert!(
814 p.validate().is_err(),
815 "mobile with 0 touch points should fail"
816 );
817 }
818
819 #[test]
820 fn non_power_of_two_memory_fails() {
821 let mut p = FingerprintProfile::windows_chrome_136_rtx3060();
822 p.hardware.memory_gb = 6;
823 assert!(p.validate().is_err(), "6 GB is not a power of 2");
824 }
825
826 #[test]
827 fn random_weighted_is_valid() {
828 for _ in 0..20 {
830 let p = FingerprintProfile::random_weighted();
831 let validation = p.validate();
832 assert!(
833 validation.is_ok(),
834 "random_weighted() produced invalid profile '{}': {validation:?}",
835 p.name,
836 );
837 }
838 }
839
840 #[test]
841 fn random_weighted_windows_majority() {
842 let n = 1000_usize;
843 let windows_count = (0..n)
844 .filter(|_| FingerprintProfile::random_weighted().platform.os == Os::Windows)
845 .count();
846 assert!(
848 windows_count > 550,
849 "expected Windows > 55% in {n} samples, got {windows_count}"
850 );
851 }
852
853 #[test]
854 fn toml_round_trip() {
855 let p = FingerprintProfile::windows_chrome_136_rtx3060();
856 let toml_result = toml::to_string(&p);
857 assert!(
858 toml_result.is_ok(),
859 "serialize to TOML failed: {toml_result:?}"
860 );
861 let Ok(toml_str) = toml_result else {
862 return;
863 };
864 let profile_result: Result<FingerprintProfile, _> = toml::from_str(&toml_str);
865 assert!(
866 profile_result.is_ok(),
867 "deserialize from TOML failed: {profile_result:?}"
868 );
869 let Ok(p2) = profile_result else {
870 return;
871 };
872 assert_eq!(p.name, p2.name);
873 assert_eq!(p.hardware.cores, p2.hardware.cores);
874 assert_eq!(p.noise_seed.as_u64(), p2.noise_seed.as_u64());
875 }
876}