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>> {
666 let mut errors: Vec<String> = Vec::new();
667
668 if matches!(self.platform.os, Os::Android | Os::Ios) && self.platform.max_touch_points == 0
670 {
671 errors.push(format!(
672 "Mobile OS {:?} must have max_touch_points > 0 (got 0)",
673 self.platform.os
674 ));
675 }
676
677 if matches!(self.platform.os, Os::Windows | Os::MacOs | Os::Linux)
679 && self.platform.max_touch_points > 0
680 && self.platform.max_touch_points < 5
681 {
682 errors.push(format!(
685 "Desktop OS {:?} has suspicious max_touch_points {} (expected 0 or ≥5 for touch-screen)",
686 self.platform.os, self.platform.max_touch_points
687 ));
688 }
689
690 if self.hardware.cores == 0 || self.hardware.cores > 128 {
692 errors.push(format!(
693 "hardwareConcurrency {} is out of range [1, 128]",
694 self.hardware.cores
695 ));
696 }
697
698 let mem = self.hardware.memory_gb;
700 if mem == 0 || !mem.is_power_of_two() || mem > 32 {
701 errors.push(format!(
702 "deviceMemory {mem} GB is not a power-of-two in [1, 32]"
703 ));
704 }
705
706 if self.screen.width == 0 || self.screen.height == 0 {
708 errors.push("screen width/height must be non-zero".into());
709 }
710
711 if self.screen.avail_width > self.screen.width
713 || self.screen.avail_height > self.screen.height
714 {
715 errors.push(format!(
716 "avail_width/avail_height ({}/{}) must be ≤ screen size ({}/{})",
717 self.screen.avail_width,
718 self.screen.avail_height,
719 self.screen.width,
720 self.screen.height
721 ));
722 }
723
724 if self.screen.dpr <= 0.0 {
726 errors.push(format!("dpr {} must be > 0", self.screen.dpr));
727 }
728
729 if self.platform.os == Os::Windows && self.platform.platform_string.contains("Mac") {
731 errors.push(format!(
732 "Windows OS has macOS-indicating platform_string '{}'",
733 self.platform.platform_string
734 ));
735 }
736
737 if self.platform.os == Os::MacOs && self.platform.platform_string.contains("Win") {
739 errors.push(format!(
740 "macOS OS has Windows-indicating platform_string '{}'",
741 self.platform.platform_string
742 ));
743 }
744
745 let is_mobile_ua = self.browser.sec_ch_ua_mobile == "?1";
747 let is_mobile_os = matches!(self.platform.os, Os::Android | Os::Ios);
748 if is_mobile_ua != is_mobile_os {
749 errors.push(format!(
750 "sec_ch_ua_mobile '{}' inconsistent with OS {:?}",
751 self.browser.sec_ch_ua_mobile, self.platform.os
752 ));
753 }
754
755 let (vpw, vph) = self.webgl.max_viewport_dims;
757 if self.webgl.max_texture_size > vpw || self.webgl.max_texture_size > vph {
758 errors.push(format!(
759 "WebGL max_texture_size {} exceeds max_viewport_dims ({},{})",
760 self.webgl.max_texture_size, vpw, vph
761 ));
762 }
763
764 if errors.is_empty() {
765 Ok(())
766 } else {
767 Err(errors)
768 }
769 }
770}
771
772#[cfg(test)]
777mod tests {
778 use super::*;
779
780 #[test]
781 fn all_builtin_profiles_are_valid() {
782 let profiles = [
783 FingerprintProfile::windows_chrome_136_rtx3060(),
784 FingerprintProfile::windows_chrome_136_gtx1660(),
785 FingerprintProfile::macos_chrome_136_m1(),
786 FingerprintProfile::linux_chrome_136_intel(),
787 FingerprintProfile::windows_edge_136_rtx3060(),
788 FingerprintProfile::android_chrome_136_pixel(),
789 ];
790 for p in &profiles {
791 let validation = p.validate();
792 assert!(
793 validation.is_ok(),
794 "profile '{}' failed validation: {validation:?}",
795 p.name
796 );
797 }
798 }
799
800 #[test]
801 fn inconsistent_profile_fails_validation() {
802 let mut p = FingerprintProfile::windows_chrome_136_rtx3060();
804 p.platform.platform_string = "MacIntel".into();
805 let result = p.validate();
806 assert!(result.is_err(), "expected validation failure");
807 let Err(errs) = result else {
808 return;
809 };
810 assert!(
811 errs.iter().any(|e| e.contains("macOS-indicating")),
812 "expected cross-OS platform_string error, got: {errs:?}"
813 );
814 }
815
816 #[test]
817 fn inconsistent_mobile_fails_validation() {
818 let mut p = FingerprintProfile::android_chrome_136_pixel();
819 p.platform.max_touch_points = 0;
820 assert!(
821 p.validate().is_err(),
822 "mobile with 0 touch points should fail"
823 );
824 }
825
826 #[test]
827 fn non_power_of_two_memory_fails() {
828 let mut p = FingerprintProfile::windows_chrome_136_rtx3060();
829 p.hardware.memory_gb = 6;
830 assert!(p.validate().is_err(), "6 GB is not a power of 2");
831 }
832
833 #[test]
834 fn random_weighted_is_valid() {
835 for _ in 0..20 {
837 let p = FingerprintProfile::random_weighted();
838 let validation = p.validate();
839 assert!(
840 validation.is_ok(),
841 "random_weighted() produced invalid profile '{}': {validation:?}",
842 p.name,
843 );
844 }
845 }
846
847 #[test]
848 fn random_weighted_windows_majority() {
849 let n = 1000_usize;
850 let windows_count = (0..n)
851 .filter(|_| FingerprintProfile::random_weighted().platform.os == Os::Windows)
852 .count();
853 assert!(
855 windows_count > 550,
856 "expected Windows > 55% in {n} samples, got {windows_count}"
857 );
858 }
859
860 #[test]
861 fn toml_round_trip() {
862 let p = FingerprintProfile::windows_chrome_136_rtx3060();
863 let toml_result = toml::to_string(&p);
864 assert!(
865 toml_result.is_ok(),
866 "serialize to TOML failed: {toml_result:?}"
867 );
868 let Ok(toml_str) = toml_result else {
869 return;
870 };
871 let profile_result: Result<FingerprintProfile, _> = toml::from_str(&toml_str);
872 assert!(
873 profile_result.is_ok(),
874 "deserialize from TOML failed: {profile_result:?}"
875 );
876 let Ok(p2) = profile_result else {
877 return;
878 };
879 assert_eq!(p.name, p2.name);
880 assert_eq!(p.hardware.cores, p2.hardware.cores);
881 assert_eq!(p.noise_seed.as_u64(), p2.noise_seed.as_u64());
882 }
883}