Skip to main content

stygian_browser/
profile.rs

1//! Unified fingerprint identity profile.
2//!
3//! [`FingerprintProfile`] ties together all identity signals — UA, platform,
4//! screen, hardware, WebGL GPU, noise seed, navigator properties, and network
5//! characteristics — into a single coherent device identity. All built-in
6//! profiles are internally consistent and pass `validate()`.
7//!
8//! # Example
9//!
10//! ```
11//! use stygian_browser::profile::FingerprintProfile;
12//!
13//! let p = FingerprintProfile::windows_chrome_136_rtx3060();
14//! let _ = p.validate();
15//! assert_eq!(p.platform.os, stygian_browser::profile::Os::Windows);
16//! ```
17
18use serde::{Deserialize, Serialize};
19
20use crate::{noise::NoiseSeed, webgl_noise::WebGlProfile};
21
22// ---------------------------------------------------------------------------
23// Sub-types
24// ---------------------------------------------------------------------------
25
26/// Operating system class.
27#[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/// Platform configuration.
38///
39/// # Example
40///
41/// ```
42/// use stygian_browser::profile::PlatformProfile;
43/// let p = PlatformProfile::windows();
44/// assert_eq!(p.max_touch_points, 0);
45/// ```
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct PlatformProfile {
48    /// Operating system.
49    pub os: Os,
50    /// Human-readable OS version string (e.g. `"10.0.0"`).
51    pub os_version: String,
52    /// `navigator.platform` value.
53    pub platform_string: String,
54    /// `navigator.maxTouchPoints` — 0 for desktop, ≥5 for mobile.
55    pub max_touch_points: u8,
56    /// Keyboard layout (e.g. `"en-US"`).
57    pub keyboard_layout: String,
58}
59
60impl PlatformProfile {
61    /// Windows 10 desktop platform.
62    #[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    /// macOS (Apple Silicon) desktop platform.
74    #[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    /// Linux desktop platform.
86    #[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    /// Android mobile platform.
98    #[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/// Browser kind.
111#[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/// Browser identity configuration.
121///
122/// # Example
123///
124/// ```
125/// use stygian_browser::profile::BrowserProfile;
126/// let b = BrowserProfile::chrome_136_windows();
127/// assert!(b.user_agent.contains("Chrome/136"));
128/// ```
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct BrowserProfile {
131    /// Browser type.
132    pub kind: BrowserKind,
133    /// Major version number.
134    pub version: u32,
135    /// Full `User-Agent` string.
136    pub user_agent: String,
137    /// `Sec-CH-UA` header value.
138    pub sec_ch_ua: String,
139    /// `Sec-CH-UA-Platform` header value.
140    pub sec_ch_ua_platform: String,
141    /// `Sec-CH-UA-Mobile` header value (`?0` or `?1`).
142    pub sec_ch_ua_mobile: String,
143}
144
145impl BrowserProfile {
146    /// Chrome 136 on Windows.
147    #[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    /// Chrome 136 on macOS.
160    #[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    /// Chrome 136 on Linux.
173    #[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    /// Edge 136 on Windows.
186    #[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    /// Chrome 136 on Android.
199    #[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/// Screen configuration.
213///
214/// # Example
215///
216/// ```
217/// use stygian_browser::profile::ScreenProfile;
218/// let s = ScreenProfile::fhd_desktop();
219/// assert_eq!(s.width, 1920);
220/// assert_eq!(s.height, 1080);
221/// ```
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct ScreenProfile {
224    /// Screen width in CSS pixels.
225    pub width: u32,
226    /// Screen height in CSS pixels.
227    pub height: u32,
228    /// Available width (typically same as `width`).
229    pub avail_width: u32,
230    /// Available height (taskbar deducted — typically `height - 40`).
231    pub avail_height: u32,
232    /// Device pixel ratio (1.0, 1.25, 1.5, 2.0, etc.).
233    pub dpr: f64,
234    /// Colour depth in bits (24 or 32).
235    pub color_depth: u8,
236    /// Screen orientation type string.
237    pub orientation: String,
238}
239
240impl ScreenProfile {
241    /// 1920×1080 FHD desktop at DPR 1.0.
242    #[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    /// 2560×1440 QHD desktop at DPR 1.0.
256    #[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    /// 2560×1600 `MacBook` Pro 14" at DPR 2.0.
270    #[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    /// 393×851 Android phone at DPR 2.75.
284    #[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/// Hardware configuration.
299///
300/// # Example
301///
302/// ```
303/// use stygian_browser::profile::HardwareProfile;
304/// let h = HardwareProfile::desktop_gaming();
305/// assert_eq!(h.cores, 8);
306/// ```
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct HardwareProfile {
309    /// `navigator.hardwareConcurrency`.
310    pub cores: u32,
311    /// `navigator.deviceMemory` in GB (must be a power of 2: 1, 2, 4, 8, 16, 32).
312    pub memory_gb: u32,
313    /// GPU vendor string (for `navigator.gpu` hint, complementing WebGL).
314    pub gpu_vendor: String,
315    /// GPU renderer string.
316    pub gpu_renderer: String,
317}
318
319impl HardwareProfile {
320    /// 8-core, 8 GB desktop gaming rig.
321    #[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    /// 8-core, 8 GB mid-range GPU desktop.
332    #[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    /// Apple M1, 8 cores, 8 GB.
343    #[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    /// Intel 4-core, 4 GB budget desktop.
354    #[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    /// Mobile — 8-core Snapdragon, 4 GB.
365    #[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/// Network configuration (`NetworkInformation` API).
377///
378/// # Example
379///
380/// ```
381/// use stygian_browser::profile::NetworkProfile;
382/// let n = NetworkProfile::fast_wifi();
383/// assert_eq!(n.effective_type, "4g");
384/// ```
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct NetworkProfile {
387    /// Round-trip time in milliseconds.
388    pub rtt: u32,
389    /// Downlink speed in Mbps.
390    pub downlink: f64,
391    /// Effective connection type (`"4g"`, `"3g"`, etc.).
392    pub effective_type: String,
393    /// `navigator.connection.saveData`.
394    pub save_data: bool,
395}
396
397impl NetworkProfile {
398    /// Typical home `WiFi` / broadband connection profile.
399    #[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    /// Mobile 4G LTE profile.
410    #[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// ---------------------------------------------------------------------------
422// FingerprintProfile — unified identity
423// ---------------------------------------------------------------------------
424
425/// A complete, internally consistent device identity for anti-fingerprinting.
426///
427/// All signals (UA, platform, screen, hardware, WebGL GPU, noise seed, network)
428/// are bundled together so they can never contradict each other.
429///
430/// Use one of the built-in constructors or call [`FingerprintProfile::validate`]
431/// to check a custom profile for consistency.
432///
433/// # Example
434///
435/// ```
436/// use stygian_browser::profile::FingerprintProfile;
437///
438/// let p = FingerprintProfile::windows_chrome_136_rtx3060();
439/// let _ = p.validate();
440/// ```
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct FingerprintProfile {
443    /// Human-readable profile name (e.g. `"windows-chrome-136-rtx3060"`).
444    pub name: String,
445    /// Platform / OS identity.
446    pub platform: PlatformProfile,
447    /// Browser identity.
448    pub browser: BrowserProfile,
449    /// Screen dimensions.
450    pub screen: ScreenProfile,
451    /// Hardware concurrency and memory.
452    pub hardware: HardwareProfile,
453    /// Detailed WebGL device profile.
454    pub webgl: WebGlProfile,
455    /// Network Information API values.
456    pub network: NetworkProfile,
457    /// Deterministic noise seed for session-unique fingerprint perturbation.
458    pub noise_seed: NoiseSeed,
459}
460
461impl FingerprintProfile {
462    // ── Built-in profiles ────────────────────────────────────────────────
463
464    /// Windows 10, Chrome 136, NVIDIA RTX 3060 — primary test profile.
465    ///
466    /// # Example
467    ///
468    /// ```
469    /// use stygian_browser::profile::FingerprintProfile;
470    /// let p = FingerprintProfile::windows_chrome_136_rtx3060();
471    /// assert!(p.validate().is_ok());
472    /// ```
473    #[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    /// Windows 10, Chrome 136, NVIDIA GTX 1660 Ti.
488    ///
489    /// # Example
490    ///
491    /// ```
492    /// use stygian_browser::profile::FingerprintProfile;
493    /// let p = FingerprintProfile::windows_chrome_136_gtx1660();
494    /// assert!(p.validate().is_ok());
495    /// ```
496    #[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    /// macOS Sonoma, Chrome 136, Apple M1.
511    ///
512    /// # Example
513    ///
514    /// ```
515    /// use stygian_browser::profile::FingerprintProfile;
516    /// let p = FingerprintProfile::macos_chrome_136_m1();
517    /// assert!(p.validate().is_ok());
518    /// ```
519    #[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(), // placeholder — M1 profile close enough
528            network: NetworkProfile::fast_wifi(),
529            noise_seed: NoiseSeed::from(0x1d2e_3f4a_5b6c_7d8e_u64),
530        }
531    }
532
533    /// Linux, Chrome 136, Intel UHD 630.
534    ///
535    /// # Example
536    ///
537    /// ```
538    /// use stygian_browser::profile::FingerprintProfile;
539    /// let p = FingerprintProfile::linux_chrome_136_intel();
540    /// assert!(p.validate().is_ok());
541    /// ```
542    #[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    /// Windows 10, Microsoft Edge 136, NVIDIA RTX 3060.
557    ///
558    /// # Example
559    ///
560    /// ```
561    /// use stygian_browser::profile::FingerprintProfile;
562    /// let p = FingerprintProfile::windows_edge_136_rtx3060();
563    /// assert!(p.validate().is_ok());
564    /// ```
565    #[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    /// Android 13 Pixel 7, Chrome 136, Adreno 730.
580    ///
581    /// # Example
582    ///
583    /// ```
584    /// use stygian_browser::profile::FingerprintProfile;
585    /// let p = FingerprintProfile::android_chrome_136_pixel();
586    /// assert!(p.validate().is_ok());
587    /// ```
588    #[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(), // Adreno-equivalent WebGL params
597            network: NetworkProfile::mobile_4g(),
598            noise_seed: NoiseSeed::from(0x4c8a_0f1e_2d3b_5a69_u64),
599        }
600    }
601
602    // ── Random weighted ──────────────────────────────────────────────────
603
604    /// Return a profile sampled by real-world market-share distribution.
605    ///
606    /// Windows ~65%, macOS ~20%, Linux ~5%, Android ~10%.
607    ///
608    /// # Example
609    ///
610    /// ```
611    /// use stygian_browser::profile::FingerprintProfile;
612    /// let p = FingerprintProfile::random_weighted();
613    /// assert!(p.validate().is_ok());
614    /// ```
615    #[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        // splitmix64 step
624        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%
630            65..=84 => Self::macos_chrome_136_m1(),       // 20%
631            85..=89 => Self::linux_chrome_136_intel(),    // 5%
632            _ => Self::android_chrome_136_pixel(),        // 10%
633        }
634    }
635
636    // ── Validation ───────────────────────────────────────────────────────
637
638    /// Validate internal consistency of the profile.
639    ///
640    /// Returns `Ok(())` if all checks pass, or `Err(Vec<String>)` with a list
641    /// of human-readable error messages explaining each inconsistency.
642    ///
643    /// # Example
644    ///
645    /// ```
646    /// use stygian_browser::profile::FingerprintProfile;
647    ///
648    /// let mut p = FingerprintProfile::windows_chrome_136_rtx3060();
649    /// // Deliberately break it: claim macOS platform but keep Windows WebGL.
650    /// p.platform.platform_string = "MacIntel".into();
651    /// // Observe baseline validation outcome for the selected profile.
652    /// let _ = p.validate();
653    ///
654    /// // Insert a real inconsistency: desktop with suspicious touch points in [1, 4].
655    /// p.platform.max_touch_points = 3;
656    /// assert!(p.validate().is_err());
657    /// ```
658    pub fn validate(&self) -> Result<(), Vec<String>> {
659        let mut errors: Vec<String> = Vec::new();
660
661        // Rule: mobile OS must have touch points > 0
662        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        // Rule: desktop OS should have touch points == 0
671        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            // Note: Windows touch-screen desktops do have 10 points; suspicious values
676            // in [1,4] are flagged.
677            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        // Rule: hardwareConcurrency must be reasonable
684        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        // Rule: deviceMemory must be a power of 2
692        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        // Rule: screen resolution must be non-zero
700        if self.screen.width == 0 || self.screen.height == 0 {
701            errors.push("screen width/height must be non-zero".into());
702        }
703
704        // Rule: avail must be ≤ full screen
705        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        // Rule: DPR must be positive
718        if self.screen.dpr <= 0.0 {
719            errors.push(format!("dpr {} must be > 0", self.screen.dpr));
720        }
721
722        // Rule: Windows platform_string should not contain "Mac"
723        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        // Rule: macOS platform_string should not contain "Win"
731        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        // Rule: sec_ch_ua_mobile must match OS mobility
739        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        // Rule: max_texture_size must fit in max_viewport_dims
749        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// ---------------------------------------------------------------------------
766// Tests
767// ---------------------------------------------------------------------------
768
769#[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        // Build a Windows profile and give it a macOS platform_string
796        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        // Run enough times to hit most branches
829        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        // With 65% target, expect at least 55% in 1000 samples
847        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}