Skip to main content

stygian_browser/
fingerprint.rs

1//! Browser fingerprint generation and JavaScript injection.
2//!
3//! Generates realistic, randomised browser fingerprints and produces JavaScript
4//! strings suitable for `Page.addScriptToEvaluateOnNewDocument` so every new
5//! page context starts with a consistent, spoofed identity.
6//!
7//! # Example
8//!
9//! ```
10//! use stygian_browser::fingerprint::{Fingerprint, inject_fingerprint};
11//!
12//! let fp = Fingerprint::random();
13//! let script = inject_fingerprint(&fp);
14//! assert!(!script.is_empty());
15//! assert!(script.contains("screen"));
16//! ```
17
18use serde::{Deserialize, Serialize};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21// ── curated value pools ──────────────────────────────────────────────────────
22
23const SCREEN_RESOLUTIONS: &[(u32, u32)] = &[
24    (1920, 1080),
25    (2560, 1440),
26    (1440, 900),
27    (1366, 768),
28    (1536, 864),
29    (1280, 800),
30    (2560, 1600),
31    (1680, 1050),
32];
33
34const TIMEZONES: &[&str] = &[
35    "America/New_York",
36    "America/Chicago",
37    "America/Denver",
38    "America/Los_Angeles",
39    "Europe/London",
40    "Europe/Paris",
41    "Europe/Berlin",
42    "Asia/Tokyo",
43    "Asia/Shanghai",
44    "Australia/Sydney",
45];
46
47const LANGUAGES: &[&str] = &[
48    "en-US", "en-GB", "en-AU", "en-CA", "fr-FR", "de-DE", "es-ES", "it-IT", "pt-BR", "ja-JP",
49    "zh-CN",
50];
51
52const HARDWARE_CONCURRENCY: &[u32] = &[4, 8, 12, 16];
53const DEVICE_MEMORY: &[u32] = &[4, 8, 16];
54
55/// (vendor, renderer) pairs that correspond to real GPU configurations.
56const WEBGL_PROFILES: &[(&str, &str, &str)] = &[
57    ("Intel Inc.", "Intel Iris OpenGL Engine", "MacIntel"),
58    ("Intel Inc.", "Intel UHD Graphics 630", "MacIntel"),
59    (
60        "Google Inc. (Apple)",
61        "ANGLE (Apple, Apple M2, OpenGL 4.1)",
62        "MacIntel",
63    ),
64    (
65        "Google Inc. (NVIDIA)",
66        "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080, OpenGL 4.1)",
67        "Win32",
68    ),
69    (
70        "Google Inc. (Intel)",
71        "ANGLE (Intel, Intel(R) UHD Graphics 770, OpenGL 4.6)",
72        "Win32",
73    ),
74    (
75        "Google Inc. (AMD)",
76        "ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0)",
77        "Win32",
78    ),
79];
80
81// Windows-only GPU pool (2-tuple; no platform tag needed)
82const WINDOWS_WEBGL_PROFILES: &[(&str, &str)] = &[
83    (
84        "Google Inc. (NVIDIA)",
85        "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080, OpenGL 4.1)",
86    ),
87    (
88        "Google Inc. (Intel)",
89        "ANGLE (Intel, Intel(R) UHD Graphics 770, OpenGL 4.6)",
90    ),
91    (
92        "Google Inc. (AMD)",
93        "ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0)",
94    ),
95];
96
97// macOS-only GPU pool
98const MACOS_WEBGL_PROFILES: &[(&str, &str)] = &[
99    ("Intel Inc.", "Intel Iris OpenGL Engine"),
100    ("Intel Inc.", "Intel UHD Graphics 630"),
101    ("Google Inc. (Apple)", "ANGLE (Apple, Apple M2, OpenGL 4.1)"),
102];
103
104// Mobile screen resolution pools
105const MOBILE_ANDROID_RESOLUTIONS: &[(u32, u32)] =
106    &[(393, 851), (390, 844), (412, 915), (414, 896), (360, 780)];
107
108const MOBILE_IOS_RESOLUTIONS: &[(u32, u32)] =
109    &[(390, 844), (393, 852), (375, 667), (414, 896), (428, 926)];
110
111// Mobile GPU pools
112const ANDROID_WEBGL_PROFILES: &[(&str, &str)] = &[
113    ("Qualcomm", "Adreno (TM) 730"),
114    ("ARM", "Mali-G710 MC10"),
115    (
116        "Google Inc. (Qualcomm)",
117        "ANGLE (Qualcomm, Adreno (TM) 730, OpenGL ES 3.2)",
118    ),
119    ("Google Inc. (ARM)", "ANGLE (ARM, Mali-G610, OpenGL ES 3.2)"),
120];
121
122const IOS_WEBGL_PROFILES: &[(&str, &str)] = &[
123    ("Apple Inc.", "Apple A16 GPU"),
124    ("Apple Inc.", "Apple A15 GPU"),
125    ("Apple Inc.", "Apple A14 GPU"),
126    ("Apple Inc.", "Apple M1"),
127];
128
129// System font pools representative of each OS
130const WINDOWS_FONTS: &[&str] = &[
131    "Arial",
132    "Calibri",
133    "Cambria",
134    "Comic Sans MS",
135    "Consolas",
136    "Courier New",
137    "Georgia",
138    "Impact",
139    "Segoe UI",
140    "Tahoma",
141    "Times New Roman",
142    "Trebuchet MS",
143    "Verdana",
144];
145
146const MACOS_FONTS: &[&str] = &[
147    "Arial",
148    "Avenir",
149    "Baskerville",
150    "Courier New",
151    "Futura",
152    "Georgia",
153    "Helvetica Neue",
154    "Lucida Grande",
155    "Optima",
156    "Palatino",
157    "Times New Roman",
158    "Verdana",
159];
160
161const LINUX_FONTS: &[&str] = &[
162    "Arial",
163    "DejaVu Sans",
164    "DejaVu Serif",
165    "FreeMono",
166    "Liberation Mono",
167    "Liberation Sans",
168    "Liberation Serif",
169    "Times New Roman",
170    "Ubuntu",
171];
172
173const MOBILE_ANDROID_FONTS: &[&str] = &[
174    "Roboto",
175    "Noto Sans",
176    "Droid Sans",
177    "sans-serif",
178    "serif",
179    "monospace",
180];
181
182const MOBILE_IOS_FONTS: &[&str] = &[
183    "Helvetica Neue",
184    "Arial",
185    "Georgia",
186    "Times New Roman",
187    "Courier New",
188];
189
190// Browser version pools
191const CHROME_VERSIONS: &[u32] = &[120, 121, 122, 123, 124, 125];
192const EDGE_VERSIONS: &[u32] = &[120, 121, 122, 123, 124];
193const FIREFOX_VERSIONS: &[u32] = &[121, 122, 123, 124, 125, 126];
194const SAFARI_VERSIONS: &[&str] = &["17.0", "17.1", "17.2", "17.3", "17.4"];
195const IOS_OS_VERSIONS: &[&str] = &["16_6", "17_0", "17_1", "17_2", "17_3"];
196
197// ── entropy helpers ──────────────────────────────────────────────────────────
198
199/// Splitmix64-style hash — mixes `seed` with a `step` multiplier so every
200/// call with a unique `step` produces an independent random-looking value.
201const fn rng(seed: u64, step: u64) -> u64 {
202    let x = seed.wrapping_add(step.wrapping_mul(0x9e37_79b9_7f4a_7c15));
203    let x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
204    let x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
205    x ^ (x >> 31)
206}
207
208fn pick<T: Copy + Default>(items: &[T], entropy: u64) -> T {
209    let idx = usize::try_from(entropy).unwrap_or(usize::MAX) % items.len().max(1);
210    items.get(idx).copied().unwrap_or_default()
211}
212
213// ── public types ─────────────────────────────────────────────────────────────
214
215/// A complete browser fingerprint used to make each session look unique.
216///
217/// # Example
218///
219/// ```
220/// use stygian_browser::fingerprint::Fingerprint;
221///
222/// let fp = Fingerprint::random();
223/// let (w, h) = fp.screen_resolution;
224/// assert!(w > 0 && h > 0);
225/// ```
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Fingerprint {
228    /// Full user-agent string.
229    pub user_agent: String,
230
231    /// Physical screen resolution `(width, height)` in pixels.
232    pub screen_resolution: (u32, u32),
233
234    /// IANA timezone identifier, e.g. `"America/New_York"`.
235    pub timezone: String,
236
237    /// BCP 47 primary language tag, e.g. `"en-US"`.
238    pub language: String,
239
240    /// Navigator platform string, e.g. `"MacIntel"` or `"Win32"`.
241    pub platform: String,
242
243    /// Logical CPU core count reported to JavaScript.
244    pub hardware_concurrency: u32,
245
246    /// Device memory in GiB reported to JavaScript.
247    pub device_memory: u32,
248
249    /// WebGL `GL_VENDOR` string.
250    pub webgl_vendor: Option<String>,
251
252    /// WebGL `GL_RENDERER` string.
253    pub webgl_renderer: Option<String>,
254
255    /// Whether to inject imperceptible canvas pixel noise.
256    pub canvas_noise: bool,
257
258    /// System fonts available on this device.
259    ///
260    /// Populated by [`Fingerprint::from_device_profile`]. Empty when created
261    /// via [`Fingerprint::random`] or `Default`.
262    pub fonts: Vec<String>,
263}
264
265impl Default for Fingerprint {
266    fn default() -> Self {
267        Self {
268            user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
269                         AppleWebKit/537.36 (KHTML, like Gecko) \
270                         Chrome/120.0.0.0 Safari/537.36"
271                .to_string(),
272            screen_resolution: (1920, 1080),
273            timezone: "America/New_York".to_string(),
274            language: "en-US".to_string(),
275            platform: "MacIntel".to_string(),
276            hardware_concurrency: 8,
277            device_memory: 8,
278            webgl_vendor: Some("Intel Inc.".to_string()),
279            webgl_renderer: Some("Intel Iris OpenGL Engine".to_string()),
280            canvas_noise: true,
281            fonts: vec![],
282        }
283    }
284}
285
286impl Fingerprint {
287    /// Generate a realistic randomised fingerprint.
288    ///
289    /// Values are selected from curated pools representative of real-world
290    /// browser distributions.  Each call uses sub-second system entropy so
291    /// consecutive calls within the same second may differ.
292    ///
293    /// # Example
294    ///
295    /// ```
296    /// use stygian_browser::fingerprint::Fingerprint;
297    ///
298    /// let fp = Fingerprint::random();
299    /// assert!(fp.hardware_concurrency > 0);
300    /// assert!(fp.device_memory > 0);
301    /// ```
302    pub fn random() -> Self {
303        let seed = SystemTime::now()
304            .duration_since(UNIX_EPOCH)
305            .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
306            .unwrap_or(0x5a5a_5a5a_5a5a_5a5a);
307
308        let res = pick(SCREEN_RESOLUTIONS, rng(seed, 1));
309        let tz = pick(TIMEZONES, rng(seed, 2));
310        let lang = pick(LANGUAGES, rng(seed, 3));
311        let hw = pick(HARDWARE_CONCURRENCY, rng(seed, 4));
312        let dm = pick(DEVICE_MEMORY, rng(seed, 5));
313        let (wv, wr, platform) = pick(WEBGL_PROFILES, rng(seed, 6));
314
315        let user_agent = if platform == "Win32" {
316            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
317             AppleWebKit/537.36 (KHTML, like Gecko) \
318             Chrome/120.0.0.0 Safari/537.36"
319                .to_string()
320        } else {
321            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
322             AppleWebKit/537.36 (KHTML, like Gecko) \
323             Chrome/120.0.0.0 Safari/537.36"
324                .to_string()
325        };
326
327        let fonts: Vec<String> = if platform == "Win32" {
328            WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect()
329        } else {
330            MACOS_FONTS.iter().map(|s| (*s).to_string()).collect()
331        };
332
333        Self {
334            user_agent,
335            screen_resolution: res,
336            timezone: tz.to_string(),
337            language: lang.to_string(),
338            platform: platform.to_string(),
339            hardware_concurrency: hw,
340            device_memory: dm,
341            webgl_vendor: Some(wv.to_string()),
342            webgl_renderer: Some(wr.to_string()),
343            canvas_noise: true,
344            fonts,
345        }
346    }
347
348    /// Clone a fingerprint from a [`FingerprintProfile`].
349    ///
350    /// # Example
351    ///
352    /// ```
353    /// use stygian_browser::fingerprint::{Fingerprint, FingerprintProfile};
354    ///
355    /// let profile = FingerprintProfile::new("test".to_string());
356    /// let fp = Fingerprint::from_profile(&profile);
357    /// assert!(!fp.user_agent.is_empty());
358    /// ```
359    pub fn from_profile(profile: &FingerprintProfile) -> Self {
360        profile.fingerprint.clone()
361    }
362
363    /// Generate a fingerprint consistent with a specific [`DeviceProfile`].
364    ///
365    /// All properties — user agent, platform, GPU, fonts — are internally
366    /// consistent.  A Mac profile will never carry a Windows GPU, for example.
367    ///
368    /// # Example
369    ///
370    /// ```
371    /// use stygian_browser::fingerprint::{Fingerprint, DeviceProfile};
372    ///
373    /// let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
374    /// assert_eq!(fp.platform, "MacIntel");
375    /// assert!(!fp.fonts.is_empty());
376    /// ```
377    pub fn from_device_profile(device: DeviceProfile, seed: u64) -> Self {
378        match device {
379            DeviceProfile::DesktopWindows => Self::for_windows(seed),
380            DeviceProfile::DesktopMac => Self::for_mac(seed),
381            DeviceProfile::DesktopLinux => Self::for_linux(seed),
382            DeviceProfile::MobileAndroid => Self::for_android(seed),
383            DeviceProfile::MobileIOS => Self::for_ios(seed),
384        }
385    }
386
387    /// Check that all fingerprint fields are internally consistent.
388    ///
389    /// Returns a `Vec<String>` of human-readable inconsistency descriptions.
390    /// An empty vec means the fingerprint passes every check.
391    ///
392    /// # Example
393    ///
394    /// ```
395    /// use stygian_browser::fingerprint::Fingerprint;
396    ///
397    /// let fp = Fingerprint::default();
398    /// assert!(fp.validate_consistency().is_empty());
399    /// ```
400    pub fn validate_consistency(&self) -> Vec<String> {
401        let mut issues = Vec::new();
402
403        // UA / platform cross-check
404        if self.platform == "Win32" && self.user_agent.contains("Mac OS X") {
405            issues.push("Win32 platform but user-agent says Mac OS X".to_string());
406        }
407        if self.platform == "MacIntel" && self.user_agent.contains("Windows NT") {
408            issues.push("MacIntel platform but user-agent says Windows NT".to_string());
409        }
410        if self.platform.starts_with("Linux") && self.user_agent.contains("Windows NT") {
411            issues.push("Linux platform but user-agent says Windows NT".to_string());
412        }
413
414        // WebGL vendor / platform cross-check
415        if let Some(vendor) = &self.webgl_vendor {
416            if (self.platform == "Win32" || self.platform == "MacIntel")
417                && (vendor.contains("Qualcomm")
418                    || vendor.contains("Adreno")
419                    || vendor.contains("Mali"))
420            {
421                issues.push(format!(
422                    "Desktop platform '{}' has mobile GPU vendor '{vendor}'",
423                    self.platform
424                ));
425            }
426            if self.platform == "Win32" && vendor.starts_with("Apple") {
427                issues.push(format!("Win32 platform has Apple GPU vendor '{vendor}'"));
428            }
429        }
430
431        // Font / platform cross-check (only when fonts are populated)
432        if !self.fonts.is_empty() {
433            let has_win_exclusive = self
434                .fonts
435                .iter()
436                .any(|f| matches!(f.as_str(), "Segoe UI" | "Calibri" | "Consolas" | "Tahoma"));
437            let has_mac_exclusive = self.fonts.iter().any(|f| {
438                matches!(
439                    f.as_str(),
440                    "Lucida Grande" | "Avenir" | "Optima" | "Futura" | "Baskerville"
441                )
442            });
443            let has_linux_exclusive = self.fonts.iter().any(|f| {
444                matches!(
445                    f.as_str(),
446                    "DejaVu Sans" | "Liberation Sans" | "Ubuntu" | "FreeMono"
447                )
448            });
449
450            if self.platform == "MacIntel" && has_win_exclusive {
451                issues.push("MacIntel platform has Windows-exclusive fonts".to_string());
452            }
453            if self.platform == "Win32" && has_mac_exclusive {
454                issues.push("Win32 platform has macOS-exclusive fonts".to_string());
455            }
456            if self.platform == "Win32" && has_linux_exclusive {
457                issues.push("Win32 platform has Linux-exclusive fonts".to_string());
458            }
459        }
460
461        issues
462    }
463
464    // ── Private per-OS fingerprint builders ───────────────────────────────────
465
466    fn for_windows(seed: u64) -> Self {
467        let browser = BrowserKind::for_device(DeviceProfile::DesktopWindows, seed);
468        let user_agent = match browser {
469            BrowserKind::Chrome | BrowserKind::Safari => {
470                let ver = pick(CHROME_VERSIONS, rng(seed, 10));
471                format!(
472                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
473                     AppleWebKit/537.36 (KHTML, like Gecko) \
474                     Chrome/{ver}.0.0.0 Safari/537.36"
475                )
476            }
477            BrowserKind::Edge => {
478                let ver = pick(EDGE_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 Edg/{ver}.0.0.0"
483                )
484            }
485            BrowserKind::Firefox => {
486                let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
487                format!(
488                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{ver}.0) \
489                     Gecko/20100101 Firefox/{ver}.0"
490                )
491            }
492        };
493
494        let (webgl_vendor, webgl_renderer) = pick(WINDOWS_WEBGL_PROFILES, rng(seed, 7));
495        let fonts = WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect();
496
497        Self {
498            user_agent,
499            screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
500            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
501            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
502            platform: "Win32".to_string(),
503            hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
504            device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
505            webgl_vendor: Some(webgl_vendor.to_string()),
506            webgl_renderer: Some(webgl_renderer.to_string()),
507            canvas_noise: true,
508            fonts,
509        }
510    }
511
512    fn for_mac(seed: u64) -> Self {
513        let browser = BrowserKind::for_device(DeviceProfile::DesktopMac, seed);
514        let user_agent = match browser {
515            BrowserKind::Chrome | BrowserKind::Edge => {
516                let ver = pick(CHROME_VERSIONS, rng(seed, 10));
517                format!(
518                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
519                     AppleWebKit/537.36 (KHTML, like Gecko) \
520                     Chrome/{ver}.0.0.0 Safari/537.36"
521                )
522            }
523            BrowserKind::Safari => {
524                let ver = pick(SAFARI_VERSIONS, rng(seed, 10));
525                format!(
526                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
527                     AppleWebKit/605.1.15 (KHTML, like Gecko) \
528                     Version/{ver} Safari/605.1.15"
529                )
530            }
531            BrowserKind::Firefox => {
532                let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
533                format!(
534                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:{ver}.0) \
535                     Gecko/20100101 Firefox/{ver}.0"
536                )
537            }
538        };
539
540        let (webgl_vendor, webgl_renderer) = pick(MACOS_WEBGL_PROFILES, rng(seed, 7));
541        let fonts = MACOS_FONTS.iter().map(|s| (*s).to_string()).collect();
542
543        Self {
544            user_agent,
545            screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
546            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
547            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
548            platform: "MacIntel".to_string(),
549            hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
550            device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
551            webgl_vendor: Some(webgl_vendor.to_string()),
552            webgl_renderer: Some(webgl_renderer.to_string()),
553            canvas_noise: true,
554            fonts,
555        }
556    }
557
558    fn for_linux(seed: u64) -> Self {
559        let browser = BrowserKind::for_device(DeviceProfile::DesktopLinux, seed);
560        let user_agent = if browser == BrowserKind::Firefox {
561            let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
562            format!(
563                "Mozilla/5.0 (X11; Linux x86_64; rv:{ver}.0) \
564                 Gecko/20100101 Firefox/{ver}.0"
565            )
566        } else {
567            let ver = pick(CHROME_VERSIONS, rng(seed, 10));
568            format!(
569                "Mozilla/5.0 (X11; Linux x86_64) \
570                 AppleWebKit/537.36 (KHTML, like Gecko) \
571                 Chrome/{ver}.0.0.0 Safari/537.36"
572            )
573        };
574
575        let fonts = LINUX_FONTS.iter().map(|s| (*s).to_string()).collect();
576
577        Self {
578            user_agent,
579            screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
580            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
581            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
582            platform: "Linux x86_64".to_string(),
583            hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
584            device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
585            webgl_vendor: Some("Mesa/X.org".to_string()),
586            webgl_renderer: Some("llvmpipe (LLVM 15.0.7, 256 bits)".to_string()),
587            canvas_noise: true,
588            fonts,
589        }
590    }
591
592    fn for_android(seed: u64) -> Self {
593        let browser = BrowserKind::for_device(DeviceProfile::MobileAndroid, seed);
594        let user_agent = if browser == BrowserKind::Firefox {
595            let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
596            format!(
597                "Mozilla/5.0 (Android 14; Mobile; rv:{ver}.0) \
598                 Gecko/20100101 Firefox/{ver}.0"
599            )
600        } else {
601            let ver = pick(CHROME_VERSIONS, rng(seed, 10));
602            format!(
603                "Mozilla/5.0 (Linux; Android 14; Pixel 7) \
604                 AppleWebKit/537.36 (KHTML, like Gecko) \
605                 Chrome/{ver}.0.6099.144 Mobile Safari/537.36"
606            )
607        };
608
609        let (webgl_vendor, webgl_renderer) = pick(ANDROID_WEBGL_PROFILES, rng(seed, 6));
610        let fonts = MOBILE_ANDROID_FONTS
611            .iter()
612            .map(|s| (*s).to_string())
613            .collect();
614
615        Self {
616            user_agent,
617            screen_resolution: pick(MOBILE_ANDROID_RESOLUTIONS, rng(seed, 1)),
618            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
619            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
620            platform: "Linux armv8l".to_string(),
621            hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
622            device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
623            webgl_vendor: Some(webgl_vendor.to_string()),
624            webgl_renderer: Some(webgl_renderer.to_string()),
625            canvas_noise: true,
626            fonts,
627        }
628    }
629
630    fn for_ios(seed: u64) -> Self {
631        let safari_ver = pick(SAFARI_VERSIONS, rng(seed, 10));
632        let ios_ver = pick(IOS_OS_VERSIONS, rng(seed, 11));
633        let user_agent = format!(
634            "Mozilla/5.0 (iPhone; CPU iPhone OS {ios_ver} like Mac OS X) \
635             AppleWebKit/605.1.15 (KHTML, like Gecko) \
636             Version/{safari_ver} Mobile/15E148 Safari/604.1"
637        );
638
639        let (webgl_vendor, webgl_renderer) = pick(IOS_WEBGL_PROFILES, rng(seed, 6));
640        let fonts = MOBILE_IOS_FONTS.iter().map(|s| (*s).to_string()).collect();
641
642        Self {
643            user_agent,
644            screen_resolution: pick(MOBILE_IOS_RESOLUTIONS, rng(seed, 1)),
645            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
646            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
647            platform: "iPhone".to_string(),
648            hardware_concurrency: 6,
649            device_memory: 4,
650            webgl_vendor: Some(webgl_vendor.to_string()),
651            webgl_renderer: Some(webgl_renderer.to_string()),
652            canvas_noise: true,
653            fonts,
654        }
655    }
656
657    /// Produce a JavaScript IIFE that spoofs browser fingerprint APIs.
658    ///
659    /// The returned script is intended to be passed to the CDP command
660    /// `Page.addScriptToEvaluateOnNewDocument` so it runs before page JS.
661    ///
662    /// Covers: screen dimensions, timezone, language, hardware concurrency,
663    /// device memory, WebGL parameters, canvas noise, and audio fingerprint
664    /// defence.
665    ///
666    /// # Example
667    ///
668    /// ```
669    /// use stygian_browser::fingerprint::Fingerprint;
670    ///
671    /// let fp = Fingerprint::default();
672    /// let script = fp.injection_script();
673    /// assert!(script.contains("1920"));
674    /// assert!(script.contains("screen"));
675    /// ```
676    pub fn injection_script(&self) -> String {
677        let mut parts = vec![
678            screen_script(self.screen_resolution),
679            timezone_script(&self.timezone),
680            language_script(&self.language, &self.user_agent),
681            hardware_script(self.hardware_concurrency, self.device_memory),
682        ];
683
684        if let (Some(vendor), Some(renderer)) = (&self.webgl_vendor, &self.webgl_renderer) {
685            parts.push(webgl_script(vendor, renderer));
686        }
687
688        if self.canvas_noise {
689            parts.push(canvas_noise_script());
690        }
691
692        parts.push(audio_fingerprint_script());
693
694        format!("(function() {{\n{}\n}})();", parts.join("\n\n"))
695    }
696}
697
698/// A named, reusable fingerprint identity.
699///
700/// # Example
701///
702/// ```
703/// use stygian_browser::fingerprint::FingerprintProfile;
704///
705/// let profile = FingerprintProfile::new("my-session".to_string());
706/// assert_eq!(profile.name, "my-session");
707/// ```
708#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct FingerprintProfile {
710    /// Human-readable profile name.
711    pub name: String,
712
713    /// The fingerprint data for this profile.
714    pub fingerprint: Fingerprint,
715}
716
717impl FingerprintProfile {
718    /// Create a new profile with a freshly randomised fingerprint.
719    ///
720    /// # Example
721    ///
722    /// ```
723    /// use stygian_browser::fingerprint::FingerprintProfile;
724    ///
725    /// let p = FingerprintProfile::new("bot-1".to_string());
726    /// assert!(!p.fingerprint.user_agent.is_empty());
727    /// ```
728    pub fn new(name: String) -> Self {
729        Self {
730            name,
731            fingerprint: Fingerprint::random(),
732        }
733    }
734
735    /// Create a new profile whose fingerprint is weighted by real-world market share.
736    ///
737    /// Device type (Windows/macOS/Linux) is selected via
738    /// [`DeviceProfile::random_weighted`], then a fully consistent fingerprint
739    /// is generated for that device.  The resulting fingerprint is guaranteed
740    /// to pass [`Fingerprint::validate_consistency`].
741    ///
742    /// # Example
743    ///
744    /// ```
745    /// use stygian_browser::fingerprint::FingerprintProfile;
746    ///
747    /// let profile = FingerprintProfile::random_weighted("session-1".to_string());
748    /// assert!(!profile.fingerprint.fonts.is_empty());
749    /// assert!(profile.fingerprint.validate_consistency().is_empty());
750    /// ```
751    pub fn random_weighted(name: String) -> Self {
752        let seed = std::time::SystemTime::now()
753            .duration_since(std::time::UNIX_EPOCH)
754            .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
755            .unwrap_or(0x5a5a_5a5a_5a5a_5a5a);
756
757        let device = DeviceProfile::random_weighted(seed);
758        Self {
759            name,
760            fingerprint: Fingerprint::from_device_profile(device, seed),
761        }
762    }
763}
764
765// ── public helper ────────────────────────────────────────────────────────────
766
767/// Return a JavaScript injection script for `fingerprint`.
768///
769/// Equivalent to calling [`Fingerprint::injection_script`] directly; provided
770/// as a standalone function for ergonomic use without importing the type.
771///
772/// The script should be passed to `Page.addScriptToEvaluateOnNewDocument`.
773///
774/// # Example
775///
776/// ```
777/// use stygian_browser::fingerprint::{Fingerprint, inject_fingerprint};
778///
779/// let fp = Fingerprint::default();
780/// let script = inject_fingerprint(&fp);
781/// assert!(script.starts_with("(function()"));
782/// ```
783pub fn inject_fingerprint(fingerprint: &Fingerprint) -> String {
784    fingerprint.injection_script()
785}
786
787// ── Device profile types ─────────────────────────────────────────────────────
788
789/// Device profile type for consistent fingerprint generation.
790///
791/// Determines the OS, platform string, GPU pool, and font set used when
792/// building a fingerprint via [`Fingerprint::from_device_profile`].
793///
794/// # Example
795///
796/// ```
797/// use stygian_browser::fingerprint::DeviceProfile;
798///
799/// let profile = DeviceProfile::random_weighted(12345);
800/// assert!(!profile.is_mobile());
801/// ```
802#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
803pub enum DeviceProfile {
804    /// Windows 10/11 desktop (≈70% of desktop market share).
805    #[default]
806    DesktopWindows,
807    /// macOS desktop (≈20% of desktop market share).
808    DesktopMac,
809    /// Linux desktop (≈10% of desktop market share).
810    DesktopLinux,
811    /// Android mobile device.
812    MobileAndroid,
813    /// iOS mobile device (iPhone/iPad).
814    MobileIOS,
815}
816
817impl DeviceProfile {
818    /// Select a device profile weighted by real-world desktop market share.
819    ///
820    /// Distribution: Windows 70%, macOS 20%, Linux 10%.
821    ///
822    /// # Example
823    ///
824    /// ```
825    /// use stygian_browser::fingerprint::DeviceProfile;
826    ///
827    /// // Most seeds produce DesktopWindows (70% weight).
828    /// let profile = DeviceProfile::random_weighted(0);
829    /// assert_eq!(profile, DeviceProfile::DesktopWindows);
830    /// ```
831    pub const fn random_weighted(seed: u64) -> Self {
832        let v = rng(seed, 97) % 100;
833        match v {
834            0..=69 => Self::DesktopWindows,
835            70..=89 => Self::DesktopMac,
836            _ => Self::DesktopLinux,
837        }
838    }
839
840    /// Returns `true` for mobile device profiles (Android or iOS).
841    ///
842    /// # Example
843    ///
844    /// ```
845    /// use stygian_browser::fingerprint::DeviceProfile;
846    ///
847    /// assert!(DeviceProfile::MobileAndroid.is_mobile());
848    /// assert!(!DeviceProfile::DesktopWindows.is_mobile());
849    /// ```
850    pub const fn is_mobile(self) -> bool {
851        matches!(self, Self::MobileAndroid | Self::MobileIOS)
852    }
853}
854
855/// Browser kind for user-agent string generation.
856///
857/// Used internally by [`Fingerprint::from_device_profile`] to construct
858/// realistic user-agent strings consistent with the selected device.
859///
860/// # Example
861///
862/// ```
863/// use stygian_browser::fingerprint::{BrowserKind, DeviceProfile};
864///
865/// let kind = BrowserKind::for_device(DeviceProfile::MobileIOS, 42);
866/// assert_eq!(kind, BrowserKind::Safari);
867/// ```
868#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
869pub enum BrowserKind {
870    /// Google Chrome — most common desktop browser.
871    #[default]
872    Chrome,
873    /// Microsoft Edge — Chromium-based, Windows-primary.
874    Edge,
875    /// Apple Safari — macOS/iOS only.
876    Safari,
877    /// Mozilla Firefox.
878    Firefox,
879}
880
881impl BrowserKind {
882    /// Select a browser weighted by market share for the given device profile.
883    ///
884    /// - iOS always returns [`BrowserKind::Safari`] (`WebKit` required).
885    /// - macOS: Chrome 56%, Safari 36%, Firefox 8%.
886    /// - Android: Chrome 90%, Firefox 10%.
887    /// - Windows/Linux: Chrome 65%, Edge 16%, Firefox 19%.
888    ///
889    /// # Example
890    ///
891    /// ```
892    /// use stygian_browser::fingerprint::{BrowserKind, DeviceProfile};
893    ///
894    /// let kind = BrowserKind::for_device(DeviceProfile::MobileIOS, 0);
895    /// assert_eq!(kind, BrowserKind::Safari);
896    /// ```
897    pub const fn for_device(device: DeviceProfile, seed: u64) -> Self {
898        match device {
899            DeviceProfile::MobileIOS => Self::Safari,
900            DeviceProfile::MobileAndroid => {
901                let v = rng(seed, 201) % 100;
902                if v < 90 { Self::Chrome } else { Self::Firefox }
903            }
904            DeviceProfile::DesktopMac => {
905                let v = rng(seed, 201) % 100;
906                match v {
907                    0..=55 => Self::Chrome,
908                    56..=91 => Self::Safari,
909                    _ => Self::Firefox,
910                }
911            }
912            _ => {
913                // Windows / Linux
914                let v = rng(seed, 201) % 100;
915                match v {
916                    0..=64 => Self::Chrome,
917                    65..=80 => Self::Edge,
918                    _ => Self::Firefox,
919                }
920            }
921        }
922    }
923}
924
925// ── JavaScript generation helpers ────────────────────────────────────────────
926
927fn screen_script((width, height): (u32, u32)) -> String {
928    // availHeight leaves ~40 px for a taskbar / dock.
929    let avail_height = height.saturating_sub(40);
930    format!(
931        r"  // Screen dimensions
932  const _defineScreen = (prop, val) =>
933    Object.defineProperty(screen, prop, {{ get: () => val, configurable: false }});
934  _defineScreen('width',       {width});
935  _defineScreen('height',      {height});
936  _defineScreen('availWidth',  {width});
937  _defineScreen('availHeight', {avail_height});
938  _defineScreen('colorDepth',  24);
939  _defineScreen('pixelDepth',  24);"
940    )
941}
942
943fn timezone_script(timezone: &str) -> String {
944    format!(
945        r"  // Timezone via Intl.DateTimeFormat
946  const _origResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
947  Intl.DateTimeFormat.prototype.resolvedOptions = function() {{
948    const opts = _origResolvedOptions.apply(this, arguments);
949    opts.timeZone = {timezone:?};
950    return opts;
951  }};"
952    )
953}
954
955fn language_script(language: &str, user_agent: &str) -> String {
956    // Build a plausible accept-languages list from the primary tag.
957    let primary = language.split('-').next().unwrap_or("en");
958    format!(
959        r"  // Language + userAgent
960  Object.defineProperty(navigator, 'language',   {{ get: () => {language:?}, configurable: false }});
961  Object.defineProperty(navigator, 'languages',  {{ get: () => Object.freeze([{language:?}, {primary:?}]), configurable: false }});
962  Object.defineProperty(navigator, 'userAgent',  {{ get: () => {user_agent:?}, configurable: false }});"
963    )
964}
965
966fn hardware_script(concurrency: u32, memory: u32) -> String {
967    format!(
968        r"  // Hardware concurrency + device memory
969  Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {concurrency}, configurable: false }});
970  Object.defineProperty(navigator, 'deviceMemory',        {{ get: () => {memory}, configurable: false }});"
971    )
972}
973
974fn webgl_script(vendor: &str, renderer: &str) -> String {
975    format!(
976        r"  // WebGL vendor + renderer
977  (function() {{
978    const _getContext = HTMLCanvasElement.prototype.getContext;
979    HTMLCanvasElement.prototype.getContext = function(type, attrs) {{
980      const ctx = _getContext.call(this, type, attrs);
981      if (!ctx) return ctx;
982      if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {{
983        const _getParam = ctx.getParameter.bind(ctx);
984        ctx.getParameter = function(param) {{
985          if (param === 0x1F00) return {vendor:?};    // GL_VENDOR
986          if (param === 0x1F01) return {renderer:?};  // GL_RENDERER
987          return _getParam(param);
988        }};
989      }}
990      return ctx;
991    }};
992  }})();"
993    )
994}
995
996fn canvas_noise_script() -> String {
997    r"  // Canvas noise: flip lowest bit of R/G/B channels to defeat pixel readback
998  (function() {
999    const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
1000    CanvasRenderingContext2D.prototype.getImageData = function() {
1001      const id = _getImageData.apply(this, arguments);
1002      const d  = id.data;
1003      for (let i = 0; i < d.length; i += 4) {
1004        d[i]     ^= 1;
1005        d[i + 1] ^= 1;
1006        d[i + 2] ^= 1;
1007      }
1008      return id;
1009    };
1010  })();"
1011        .to_string()
1012}
1013
1014fn audio_fingerprint_script() -> String {
1015    r"  // Audio fingerprint defence: add sub-epsilon noise to frequency data
1016  (function() {
1017    if (typeof AnalyserNode === 'undefined') return;
1018    const _getFloatFreq = AnalyserNode.prototype.getFloatFrequencyData;
1019    AnalyserNode.prototype.getFloatFrequencyData = function(arr) {
1020      _getFloatFreq.apply(this, arguments);
1021      for (let i = 0; i < arr.length; i++) {
1022        arr[i] += (Math.random() - 0.5) * 1e-7;
1023      }
1024    };
1025  })();"
1026        .to_string()
1027}
1028
1029// ── tests ────────────────────────────────────────────────────────────────────
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034
1035    #[test]
1036    fn random_fingerprint_has_valid_ranges() {
1037        let fp = Fingerprint::random();
1038        let (w, h) = fp.screen_resolution;
1039        assert!(
1040            (1280..=3840).contains(&w),
1041            "width {w} out of expected range"
1042        );
1043        assert!(
1044            (768..=2160).contains(&h),
1045            "height {h} out of expected range"
1046        );
1047        assert!(
1048            HARDWARE_CONCURRENCY.contains(&fp.hardware_concurrency),
1049            "hardware_concurrency {} not in pool",
1050            fp.hardware_concurrency
1051        );
1052        assert!(
1053            DEVICE_MEMORY.contains(&fp.device_memory),
1054            "device_memory {} not in pool",
1055            fp.device_memory
1056        );
1057        assert!(
1058            TIMEZONES.contains(&fp.timezone.as_str()),
1059            "timezone {} not in pool",
1060            fp.timezone
1061        );
1062        assert!(
1063            LANGUAGES.contains(&fp.language.as_str()),
1064            "language {} not in pool",
1065            fp.language
1066        );
1067    }
1068
1069    #[test]
1070    fn random_generates_different_values_over_time() {
1071        // Two calls should eventually differ across seeds; at minimum the
1072        // function must not panic and must return valid data.
1073        let fp1 = Fingerprint::random();
1074        let fp2 = Fingerprint::random();
1075        // Both are well-formed whether or not they happen to be equal.
1076        assert!(!fp1.user_agent.is_empty());
1077        assert!(!fp2.user_agent.is_empty());
1078    }
1079
1080    #[test]
1081    fn injection_script_contains_screen_dimensions() {
1082        let fp = Fingerprint {
1083            screen_resolution: (2560, 1440),
1084            ..Fingerprint::default()
1085        };
1086        let script = fp.injection_script();
1087        assert!(script.contains("2560"), "missing width in script");
1088        assert!(script.contains("1440"), "missing height in script");
1089    }
1090
1091    #[test]
1092    fn injection_script_contains_timezone() {
1093        let fp = Fingerprint {
1094            timezone: "Europe/Berlin".to_string(),
1095            ..Fingerprint::default()
1096        };
1097        let script = fp.injection_script();
1098        assert!(script.contains("Europe/Berlin"), "timezone missing");
1099    }
1100
1101    #[test]
1102    fn injection_script_contains_canvas_noise_when_enabled() {
1103        let fp = Fingerprint {
1104            canvas_noise: true,
1105            ..Fingerprint::default()
1106        };
1107        let script = fp.injection_script();
1108        assert!(
1109            script.contains("getImageData"),
1110            "canvas noise block missing"
1111        );
1112    }
1113
1114    #[test]
1115    fn injection_script_omits_canvas_noise_when_disabled() {
1116        let fp = Fingerprint {
1117            canvas_noise: false,
1118            ..Fingerprint::default()
1119        };
1120        let script = fp.injection_script();
1121        assert!(
1122            !script.contains("getImageData"),
1123            "canvas noise should be absent"
1124        );
1125    }
1126
1127    #[test]
1128    fn injection_script_contains_webgl_vendor() {
1129        let fp = Fingerprint {
1130            webgl_vendor: Some("TestVendor".to_string()),
1131            webgl_renderer: Some("TestRenderer".to_string()),
1132            canvas_noise: false,
1133            ..Fingerprint::default()
1134        };
1135        let script = fp.injection_script();
1136        assert!(script.contains("TestVendor"), "WebGL vendor missing");
1137        assert!(script.contains("TestRenderer"), "WebGL renderer missing");
1138    }
1139
1140    #[test]
1141    fn inject_fingerprint_fn_equals_method() {
1142        let fp = Fingerprint::default();
1143        assert_eq!(inject_fingerprint(&fp), fp.injection_script());
1144    }
1145
1146    #[test]
1147    fn from_profile_returns_profile_fingerprint() {
1148        let profile = FingerprintProfile::new("test".to_string());
1149        let fp = Fingerprint::from_profile(&profile);
1150        assert_eq!(fp.user_agent, profile.fingerprint.user_agent);
1151    }
1152
1153    #[test]
1154    fn script_is_wrapped_in_iife() {
1155        let script = Fingerprint::default().injection_script();
1156        assert!(script.starts_with("(function()"), "must start with IIFE");
1157        assert!(script.ends_with("})();"), "must end with IIFE call");
1158    }
1159
1160    #[test]
1161    fn rng_produces_distinct_values_for_different_steps() {
1162        let seed = 0xdead_beef_cafe_babe_u64;
1163        let v1 = rng(seed, 1);
1164        let v2 = rng(seed, 2);
1165        let v3 = rng(seed, 3);
1166        assert_ne!(v1, v2);
1167        assert_ne!(v2, v3);
1168    }
1169
1170    // ── T08 — DeviceProfile / BrowserKind / from_device_profile tests ─────────
1171
1172    #[test]
1173    fn device_profile_windows_is_consistent() {
1174        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 42);
1175        assert_eq!(fp.platform, "Win32");
1176        assert!(fp.user_agent.contains("Windows NT"), "UA must be Windows");
1177        assert!(!fp.fonts.is_empty(), "Windows profile must have fonts");
1178        assert!(
1179            fp.validate_consistency().is_empty(),
1180            "must pass consistency"
1181        );
1182    }
1183
1184    #[test]
1185    fn device_profile_mac_is_consistent() {
1186        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
1187        assert_eq!(fp.platform, "MacIntel");
1188        assert!(
1189            fp.user_agent.contains("Mac OS X"),
1190            "UA must be macOS: {}",
1191            fp.user_agent
1192        );
1193        assert!(!fp.fonts.is_empty(), "Mac profile must have fonts");
1194        assert!(
1195            fp.validate_consistency().is_empty(),
1196            "must pass consistency"
1197        );
1198    }
1199
1200    #[test]
1201    fn device_profile_linux_is_consistent() {
1202        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopLinux, 42);
1203        assert_eq!(fp.platform, "Linux x86_64");
1204        assert!(fp.user_agent.contains("Linux"), "UA must be Linux");
1205        assert!(!fp.fonts.is_empty(), "Linux profile must have fonts");
1206        assert!(
1207            fp.validate_consistency().is_empty(),
1208            "must pass consistency"
1209        );
1210    }
1211
1212    #[test]
1213    fn device_profile_android_is_mobile() {
1214        let fp = Fingerprint::from_device_profile(DeviceProfile::MobileAndroid, 42);
1215        assert!(
1216            fp.platform.starts_with("Linux"),
1217            "Android platform should be Linux-based"
1218        );
1219        assert!(
1220            fp.user_agent.contains("Android") || fp.user_agent.contains("Firefox"),
1221            "Android UA mismatch: {}",
1222            fp.user_agent
1223        );
1224        assert!(!fp.fonts.is_empty());
1225        assert!(DeviceProfile::MobileAndroid.is_mobile());
1226    }
1227
1228    #[test]
1229    fn device_profile_ios_is_mobile() {
1230        let fp = Fingerprint::from_device_profile(DeviceProfile::MobileIOS, 42);
1231        assert_eq!(fp.platform, "iPhone");
1232        assert!(
1233            fp.user_agent.contains("iPhone"),
1234            "iOS UA must contain iPhone"
1235        );
1236        assert!(!fp.fonts.is_empty());
1237        assert!(DeviceProfile::MobileIOS.is_mobile());
1238    }
1239
1240    #[test]
1241    fn desktop_profiles_are_not_mobile() {
1242        assert!(!DeviceProfile::DesktopWindows.is_mobile());
1243        assert!(!DeviceProfile::DesktopMac.is_mobile());
1244        assert!(!DeviceProfile::DesktopLinux.is_mobile());
1245    }
1246
1247    #[test]
1248    fn browser_kind_ios_always_safari() {
1249        for seed in [0u64, 1, 42, 999, u64::MAX] {
1250            assert_eq!(
1251                BrowserKind::for_device(DeviceProfile::MobileIOS, seed),
1252                BrowserKind::Safari,
1253                "iOS must always return Safari (seed={seed})"
1254            );
1255        }
1256    }
1257
1258    #[test]
1259    fn device_profile_random_weighted_distribution() {
1260        // Run 1000 samples and verify Windows dominates (expect ≥50%)
1261        let windows_count = (0u64..1000)
1262            .filter(|&i| {
1263                DeviceProfile::random_weighted(i * 13 + 7) == DeviceProfile::DesktopWindows
1264            })
1265            .count();
1266        assert!(
1267            windows_count >= 500,
1268            "Expected ≥50% Windows, got {windows_count}/1000"
1269        );
1270    }
1271
1272    #[test]
1273    fn validate_consistency_catches_platform_ua_mismatch() {
1274        let fp = Fingerprint {
1275            platform: "Win32".to_string(),
1276            user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
1277                         AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
1278                .to_string(),
1279            ..Fingerprint::default()
1280        };
1281        let issues = fp.validate_consistency();
1282        assert!(!issues.is_empty(), "should detect Win32+Mac UA mismatch");
1283    }
1284
1285    #[test]
1286    fn validate_consistency_catches_platform_font_mismatch() {
1287        let fp = Fingerprint {
1288            platform: "MacIntel".to_string(),
1289            fonts: vec!["Segoe UI".to_string(), "Calibri".to_string()],
1290            ..Fingerprint::default()
1291        };
1292        let issues = fp.validate_consistency();
1293        assert!(
1294            !issues.is_empty(),
1295            "should detect MacIntel + Windows fonts mismatch"
1296        );
1297    }
1298
1299    #[test]
1300    fn validate_consistency_passes_for_default() {
1301        let fp = Fingerprint::default();
1302        assert!(fp.validate_consistency().is_empty());
1303    }
1304
1305    #[test]
1306    fn fingerprint_profile_random_weighted_has_fonts() {
1307        let profile = FingerprintProfile::random_weighted("sess-1".to_string());
1308        assert_eq!(profile.name, "sess-1");
1309        assert!(!profile.fingerprint.fonts.is_empty());
1310        assert!(profile.fingerprint.validate_consistency().is_empty());
1311    }
1312
1313    #[test]
1314    fn from_device_profile_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
1315        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 123);
1316        let json = serde_json::to_string(&fp)?;
1317        let back: Fingerprint = serde_json::from_str(&json)?;
1318        assert_eq!(back.platform, fp.platform);
1319        assert_eq!(back.fonts, fp.fonts);
1320        Ok(())
1321    }
1322
1323    // ─── Property-based tests (proptest) ──────────────────────────────────────
1324
1325    proptest::proptest! {
1326        /// For any seed, a device-profile fingerprint must pass `validate_consistency()`.
1327        #[test]
1328        fn prop_seeded_fingerprint_always_consistent(seed in 0u64..10_000) {
1329            let profile = DeviceProfile::random_weighted(seed);
1330            let fp = Fingerprint::from_device_profile(profile, seed);
1331            let issues = fp.validate_consistency();
1332            proptest::prop_assert!(
1333                issues.is_empty(),
1334                "validate_consistency() failed for seed {seed}: {issues:?}"
1335            );
1336        }
1337
1338        /// Hardware concurrency must always be in [1, 32].
1339        #[test]
1340        fn prop_hardware_concurrency_is_sensible(_seed in 0u64..10_000) {
1341            let fp = Fingerprint::random();
1342            proptest::prop_assert!(
1343                fp.hardware_concurrency >= 1 && fp.hardware_concurrency <= 32,
1344                "hardware_concurrency {} out of [1,32]", fp.hardware_concurrency
1345            );
1346        }
1347
1348        /// Device memory must be in the valid JS set {4, 8, 16} (gb as reported to JS).
1349        #[test]
1350        fn prop_device_memory_is_valid_value(_seed in 0u64..10_000) {
1351            let fp = Fingerprint::random();
1352            let valid: &[u32] = &[4, 8, 16];
1353            proptest::prop_assert!(
1354                valid.contains(&fp.device_memory),
1355                "device_memory {} is not a valid value", fp.device_memory
1356            );
1357        }
1358
1359        /// Screen dimensions must be plausible for a real monitor.
1360        #[test]
1361        fn prop_screen_dimensions_are_plausible(_seed in 0u64..10_000) {
1362            let fp = Fingerprint::random();
1363            let (w, h) = fp.screen_resolution;
1364            proptest::prop_assert!((320..=7680).contains(&w));
1365            proptest::prop_assert!((240..=4320).contains(&h));
1366        }
1367
1368        /// FingerprintProfile::random_weighted must always pass consistency.
1369        #[test]
1370        fn prop_fingerprint_profile_passes_consistency(name in "[a-z][a-z0-9]{0,31}") {
1371            let profile = FingerprintProfile::random_weighted(name.clone());
1372            let issues = profile.fingerprint.validate_consistency();
1373            proptest::prop_assert!(
1374                issues.is_empty(),
1375                "FingerprintProfile for '{name}' has issues: {issues:?}"
1376            );
1377        }
1378
1379        /// Injection script is always non-empty and mentions navigator.
1380        #[test]
1381        fn prop_injection_script_non_empty(_seed in 0u64..10_000) {
1382            let fp = Fingerprint::random();
1383            let script = inject_fingerprint(&fp);
1384            proptest::prop_assert!(!script.is_empty());
1385            proptest::prop_assert!(script.contains("navigator"));
1386        }
1387    }
1388}