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        parts.push(connection_spoof_script());
694        parts.push(battery_spoof_script());
695        parts.push(plugins_spoof_script());
696
697        format!("(function() {{\n{}\n}})();", parts.join("\n\n"))
698    }
699}
700
701/// A named, reusable fingerprint identity.
702///
703/// # Example
704///
705/// ```
706/// use stygian_browser::fingerprint::FingerprintProfile;
707///
708/// let profile = FingerprintProfile::new("my-session".to_string());
709/// assert_eq!(profile.name, "my-session");
710/// ```
711#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct FingerprintProfile {
713    /// Human-readable profile name.
714    pub name: String,
715
716    /// The fingerprint data for this profile.
717    pub fingerprint: Fingerprint,
718}
719
720impl FingerprintProfile {
721    /// Create a new profile with a freshly randomised fingerprint.
722    ///
723    /// # Example
724    ///
725    /// ```
726    /// use stygian_browser::fingerprint::FingerprintProfile;
727    ///
728    /// let p = FingerprintProfile::new("bot-1".to_string());
729    /// assert!(!p.fingerprint.user_agent.is_empty());
730    /// ```
731    pub fn new(name: String) -> Self {
732        Self {
733            name,
734            fingerprint: Fingerprint::random(),
735        }
736    }
737
738    /// Create a new profile whose fingerprint is weighted by real-world market share.
739    ///
740    /// Device type (Windows/macOS/Linux) is selected via
741    /// [`DeviceProfile::random_weighted`], then a fully consistent fingerprint
742    /// is generated for that device.  The resulting fingerprint is guaranteed
743    /// to pass [`Fingerprint::validate_consistency`].
744    ///
745    /// # Example
746    ///
747    /// ```
748    /// use stygian_browser::fingerprint::FingerprintProfile;
749    ///
750    /// let profile = FingerprintProfile::random_weighted("session-1".to_string());
751    /// assert!(!profile.fingerprint.fonts.is_empty());
752    /// assert!(profile.fingerprint.validate_consistency().is_empty());
753    /// ```
754    pub fn random_weighted(name: String) -> Self {
755        let seed = std::time::SystemTime::now()
756            .duration_since(std::time::UNIX_EPOCH)
757            .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
758            .unwrap_or(0x5a5a_5a5a_5a5a_5a5a);
759
760        let device = DeviceProfile::random_weighted(seed);
761        Self {
762            name,
763            fingerprint: Fingerprint::from_device_profile(device, seed),
764        }
765    }
766}
767
768// ── public helper ────────────────────────────────────────────────────────────
769
770/// Return a JavaScript injection script for `fingerprint`.
771///
772/// Equivalent to calling [`Fingerprint::injection_script`] directly; provided
773/// as a standalone function for ergonomic use without importing the type.
774///
775/// The script should be passed to `Page.addScriptToEvaluateOnNewDocument`.
776///
777/// # Example
778///
779/// ```
780/// use stygian_browser::fingerprint::{Fingerprint, inject_fingerprint};
781///
782/// let fp = Fingerprint::default();
783/// let script = inject_fingerprint(&fp);
784/// assert!(script.starts_with("(function()"));
785/// ```
786pub fn inject_fingerprint(fingerprint: &Fingerprint) -> String {
787    fingerprint.injection_script()
788}
789
790// ── Device profile types ─────────────────────────────────────────────────────
791
792/// Device profile type for consistent fingerprint generation.
793///
794/// Determines the OS, platform string, GPU pool, and font set used when
795/// building a fingerprint via [`Fingerprint::from_device_profile`].
796///
797/// # Example
798///
799/// ```
800/// use stygian_browser::fingerprint::DeviceProfile;
801///
802/// let profile = DeviceProfile::random_weighted(12345);
803/// assert!(!profile.is_mobile());
804/// ```
805#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
806pub enum DeviceProfile {
807    /// Windows 10/11 desktop (≈70% of desktop market share).
808    #[default]
809    DesktopWindows,
810    /// macOS desktop (≈20% of desktop market share).
811    DesktopMac,
812    /// Linux desktop (≈10% of desktop market share).
813    DesktopLinux,
814    /// Android mobile device.
815    MobileAndroid,
816    /// iOS mobile device (iPhone/iPad).
817    MobileIOS,
818}
819
820impl DeviceProfile {
821    /// Select a device profile weighted by real-world desktop market share.
822    ///
823    /// Distribution: Windows 70%, macOS 20%, Linux 10%.
824    ///
825    /// # Example
826    ///
827    /// ```
828    /// use stygian_browser::fingerprint::DeviceProfile;
829    ///
830    /// // Most seeds produce DesktopWindows (70% weight).
831    /// let profile = DeviceProfile::random_weighted(0);
832    /// assert_eq!(profile, DeviceProfile::DesktopWindows);
833    /// ```
834    pub const fn random_weighted(seed: u64) -> Self {
835        let v = rng(seed, 97) % 100;
836        match v {
837            0..=69 => Self::DesktopWindows,
838            70..=89 => Self::DesktopMac,
839            _ => Self::DesktopLinux,
840        }
841    }
842
843    /// Returns `true` for mobile device profiles (Android or iOS).
844    ///
845    /// # Example
846    ///
847    /// ```
848    /// use stygian_browser::fingerprint::DeviceProfile;
849    ///
850    /// assert!(DeviceProfile::MobileAndroid.is_mobile());
851    /// assert!(!DeviceProfile::DesktopWindows.is_mobile());
852    /// ```
853    pub const fn is_mobile(self) -> bool {
854        matches!(self, Self::MobileAndroid | Self::MobileIOS)
855    }
856}
857
858/// Browser kind for user-agent string generation.
859///
860/// Used internally by [`Fingerprint::from_device_profile`] to construct
861/// realistic user-agent strings consistent with the selected device.
862///
863/// # Example
864///
865/// ```
866/// use stygian_browser::fingerprint::{BrowserKind, DeviceProfile};
867///
868/// let kind = BrowserKind::for_device(DeviceProfile::MobileIOS, 42);
869/// assert_eq!(kind, BrowserKind::Safari);
870/// ```
871#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
872pub enum BrowserKind {
873    /// Google Chrome — most common desktop browser.
874    #[default]
875    Chrome,
876    /// Microsoft Edge — Chromium-based, Windows-primary.
877    Edge,
878    /// Apple Safari — macOS/iOS only.
879    Safari,
880    /// Mozilla Firefox.
881    Firefox,
882}
883
884impl BrowserKind {
885    /// Select a browser weighted by market share for the given device profile.
886    ///
887    /// - iOS always returns [`BrowserKind::Safari`] (`WebKit` required).
888    /// - macOS: Chrome 56%, Safari 36%, Firefox 8%.
889    /// - Android: Chrome 90%, Firefox 10%.
890    /// - Windows/Linux: Chrome 65%, Edge 16%, Firefox 19%.
891    ///
892    /// # Example
893    ///
894    /// ```
895    /// use stygian_browser::fingerprint::{BrowserKind, DeviceProfile};
896    ///
897    /// let kind = BrowserKind::for_device(DeviceProfile::MobileIOS, 0);
898    /// assert_eq!(kind, BrowserKind::Safari);
899    /// ```
900    pub const fn for_device(device: DeviceProfile, seed: u64) -> Self {
901        match device {
902            DeviceProfile::MobileIOS => Self::Safari,
903            DeviceProfile::MobileAndroid => {
904                let v = rng(seed, 201) % 100;
905                if v < 90 { Self::Chrome } else { Self::Firefox }
906            }
907            DeviceProfile::DesktopMac => {
908                let v = rng(seed, 201) % 100;
909                match v {
910                    0..=55 => Self::Chrome,
911                    56..=91 => Self::Safari,
912                    _ => Self::Firefox,
913                }
914            }
915            _ => {
916                // Windows / Linux
917                let v = rng(seed, 201) % 100;
918                match v {
919                    0..=64 => Self::Chrome,
920                    65..=80 => Self::Edge,
921                    _ => Self::Firefox,
922                }
923            }
924        }
925    }
926}
927
928// ── JavaScript generation helpers ────────────────────────────────────────────
929
930fn screen_script((width, height): (u32, u32)) -> String {
931    // availHeight leaves ~40 px for a taskbar / dock.
932    let avail_height = height.saturating_sub(40);
933    format!(
934        r"  // Screen dimensions
935  const _defineScreen = (prop, val) =>
936    Object.defineProperty(screen, prop, {{ get: () => val, configurable: false }});
937  _defineScreen('width',       {width});
938  _defineScreen('height',      {height});
939  _defineScreen('availWidth',  {width});
940  _defineScreen('availHeight', {avail_height});
941  _defineScreen('colorDepth',  24);
942  _defineScreen('pixelDepth',  24);
943  // outerWidth/outerHeight: headless Chrome may return 0; spoof to viewport size.
944  try {{
945    Object.defineProperty(window, 'outerWidth',  {{ get: () => {width},  configurable: true }});
946    Object.defineProperty(window, 'outerHeight', {{ get: () => {height}, configurable: true }});
947  }} catch(_) {{}}"
948    )
949}
950
951fn timezone_script(timezone: &str) -> String {
952    format!(
953        r"  // Timezone via Intl.DateTimeFormat
954  const _origResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
955  Intl.DateTimeFormat.prototype.resolvedOptions = function() {{
956    const opts = _origResolvedOptions.apply(this, arguments);
957    opts.timeZone = {timezone:?};
958    return opts;
959  }};"
960    )
961}
962
963fn language_script(language: &str, user_agent: &str) -> String {
964    // Build a plausible accept-languages list from the primary tag.
965    let primary = language.split('-').next().unwrap_or("en");
966    format!(
967        r"  // Language + userAgent
968  Object.defineProperty(navigator, 'language',   {{ get: () => {language:?}, configurable: false }});
969  Object.defineProperty(navigator, 'languages',  {{ get: () => Object.freeze([{language:?}, {primary:?}]), configurable: false }});
970  Object.defineProperty(navigator, 'userAgent',  {{ get: () => {user_agent:?}, configurable: false }});"
971    )
972}
973
974fn hardware_script(concurrency: u32, memory: u32) -> String {
975    format!(
976        r"  // Hardware concurrency + device memory
977  Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {concurrency}, configurable: false }});
978  Object.defineProperty(navigator, 'deviceMemory',        {{ get: () => {memory}, configurable: false }});"
979    )
980}
981
982fn webgl_script(vendor: &str, renderer: &str) -> String {
983    format!(
984        r"  // WebGL vendor + renderer
985  (function() {{
986    const _getContext = HTMLCanvasElement.prototype.getContext;
987    HTMLCanvasElement.prototype.getContext = function(type, attrs) {{
988      const ctx = _getContext.call(this, type, attrs);
989      if (!ctx) return ctx;
990      if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {{
991        const _getParam = ctx.getParameter.bind(ctx);
992        ctx.getParameter = function(param) {{
993          if (param === 0x1F00) return {vendor:?};    // GL_VENDOR
994          if (param === 0x1F01) return {renderer:?};  // GL_RENDERER
995          return _getParam(param);
996        }};
997      }}
998      return ctx;
999    }};
1000  }})();"
1001    )
1002}
1003
1004fn canvas_noise_script() -> String {
1005    r"  // Canvas noise: flip lowest bit of R/G/B channels to defeat pixel readback
1006  (function() {
1007    const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
1008    CanvasRenderingContext2D.prototype.getImageData = function() {
1009      const id = _getImageData.apply(this, arguments);
1010      const d  = id.data;
1011      for (let i = 0; i < d.length; i += 4) {
1012        d[i]     ^= 1;
1013        d[i + 1] ^= 1;
1014        d[i + 2] ^= 1;
1015      }
1016      return id;
1017    };
1018  })();"
1019        .to_string()
1020}
1021
1022fn audio_fingerprint_script() -> String {
1023    r"  // Audio fingerprint defence: add sub-epsilon noise to frequency data
1024  (function() {
1025    if (typeof AnalyserNode === 'undefined') return;
1026    const _getFloatFreq = AnalyserNode.prototype.getFloatFrequencyData;
1027    AnalyserNode.prototype.getFloatFrequencyData = function(arr) {
1028      _getFloatFreq.apply(this, arguments);
1029      for (let i = 0; i < arr.length; i++) {
1030        arr[i] += (Math.random() - 0.5) * 1e-7;
1031      }
1032    };
1033  })();"
1034        .to_string()
1035}
1036
1037/// Spoof the `NetworkInformation` API (`navigator.connection`) so headless sessions
1038/// report a realistic `WiFi` connection rather than the undefined/zero-RTT default
1039/// that Akamai Bot Manager v3 uses as a headless indicator.
1040fn connection_spoof_script() -> String {
1041    // _seed is 0–996; derived from performance.timeOrigin (epoch ms with sub-ms
1042    // precision) so the RTT/downlink values vary realistically across sessions.
1043    r"  // NetworkInformation API spoof (navigator.connection)
1044  (function() {
1045    const _seed = Math.floor(performance.timeOrigin % 997);
1046    const conn = {
1047      rtt:           50 + _seed % 100,
1048      downlink:      5 + _seed % 15,
1049      effectiveType: '4g',
1050      type:          'wifi',
1051      saveData:      false,
1052      onchange:      null,
1053      ontypechange:  null,
1054      addEventListener:    function() {},
1055      removeEventListener: function() {},
1056      dispatchEvent:       function() { return true; },
1057    };
1058    try {
1059      Object.defineProperty(navigator, 'connection', {
1060        get: () => conn,
1061        enumerable: true,
1062        configurable: false,
1063      });
1064    } catch (_) {}
1065  })();"
1066        .to_string()
1067}
1068
1069/// Normalize `navigator.getBattery()` away from the suspicious fully-charged
1070/// headless default (`level: 1.0`, `charging: true`) that many bot detectors
1071/// flag.  Resolves to a realistic mid-charge, discharging state.
1072/// Spoof `navigator.plugins` and `navigator.mimeTypes`.
1073///
1074/// An empty `PluginArray` (length 0) is the single most-flagged headless
1075/// indicator on services like pixelscan.net and Akamai Bot Manager.  Real
1076/// Chrome always exposes at least the built-in PDF Viewer plugin.
1077fn plugins_spoof_script() -> String {
1078    r"  // navigator.plugins / mimeTypes — empty array = instant headless flag
1079  (function() {
1080    // Build minimal objects that survive instanceof checks.
1081    var mime0 = { type: 'application/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1082    var mime1 = { type: 'text/pdf',        description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1083    var pdfPlugin = {
1084      name: 'PDF Viewer',
1085      description: 'Portable Document Format',
1086      filename: 'internal-pdf-viewer',
1087      length: 2,
1088      0: mime0, 1: mime1,
1089      item: function(i) { return [mime0, mime1][i] || null; },
1090      namedItem: function(n) {
1091        if (n === 'application/pdf') return mime0;
1092        if (n === 'text/pdf')        return mime1;
1093        return null;
1094      },
1095    };
1096    mime0.enabledPlugin = pdfPlugin;
1097    mime1.enabledPlugin = pdfPlugin;
1098
1099    var fakePlugins = {
1100      length: 1,
1101      0: pdfPlugin,
1102      item: function(i) { return i === 0 ? pdfPlugin : null; },
1103      namedItem: function(n) { return n === 'PDF Viewer' ? pdfPlugin : null; },
1104      refresh: function() {},
1105    };
1106    var fakeMimes = {
1107      length: 2,
1108      0: mime0, 1: mime1,
1109      item: function(i) { return [mime0, mime1][i] || null; },
1110      namedItem: function(n) {
1111        if (n === 'application/pdf') return mime0;
1112        if (n === 'text/pdf')        return mime1;
1113        return null;
1114      },
1115    };
1116
1117    try {
1118      Object.defineProperty(navigator, 'plugins',   { get: function() { return fakePlugins; }, configurable: false });
1119      Object.defineProperty(navigator, 'mimeTypes', { get: function() { return fakeMimes; },   configurable: false });
1120    } catch(_) {}
1121  })();"
1122        .to_string()
1123}
1124
1125fn battery_spoof_script() -> String {
1126    r"  // Battery API normalization (navigator.getBattery)
1127  (function() {
1128    if (typeof navigator.getBattery !== 'function') return;
1129    const _seed = Math.floor(performance.timeOrigin % 997);
1130    const battery = {
1131      charging:        false,
1132      chargingTime:    Infinity,
1133      dischargingTime: 3600 + _seed * 7,
1134      level:           0.65 + (_seed % 30) / 100,
1135      onchargingchange:        null,
1136      onchargingtimechange:    null,
1137      ondischargingtimechange: null,
1138      onlevelchange:           null,
1139      addEventListener:    function() {},
1140      removeEventListener: function() {},
1141      dispatchEvent:       function() { return true; },
1142    };
1143    navigator.getBattery = function() {
1144      return Promise.resolve(battery);
1145    };
1146  })();"
1147        .to_string()
1148}
1149
1150// ── tests ────────────────────────────────────────────────────────────────────
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155
1156    #[test]
1157    fn random_fingerprint_has_valid_ranges() {
1158        let fp = Fingerprint::random();
1159        let (w, h) = fp.screen_resolution;
1160        assert!(
1161            (1280..=3840).contains(&w),
1162            "width {w} out of expected range"
1163        );
1164        assert!(
1165            (768..=2160).contains(&h),
1166            "height {h} out of expected range"
1167        );
1168        assert!(
1169            HARDWARE_CONCURRENCY.contains(&fp.hardware_concurrency),
1170            "hardware_concurrency {} not in pool",
1171            fp.hardware_concurrency
1172        );
1173        assert!(
1174            DEVICE_MEMORY.contains(&fp.device_memory),
1175            "device_memory {} not in pool",
1176            fp.device_memory
1177        );
1178        assert!(
1179            TIMEZONES.contains(&fp.timezone.as_str()),
1180            "timezone {} not in pool",
1181            fp.timezone
1182        );
1183        assert!(
1184            LANGUAGES.contains(&fp.language.as_str()),
1185            "language {} not in pool",
1186            fp.language
1187        );
1188    }
1189
1190    #[test]
1191    fn random_generates_different_values_over_time() {
1192        // Two calls should eventually differ across seeds; at minimum the
1193        // function must not panic and must return valid data.
1194        let fp1 = Fingerprint::random();
1195        let fp2 = Fingerprint::random();
1196        // Both are well-formed whether or not they happen to be equal.
1197        assert!(!fp1.user_agent.is_empty());
1198        assert!(!fp2.user_agent.is_empty());
1199    }
1200
1201    #[test]
1202    fn injection_script_contains_screen_dimensions() {
1203        let fp = Fingerprint {
1204            screen_resolution: (2560, 1440),
1205            ..Fingerprint::default()
1206        };
1207        let script = fp.injection_script();
1208        assert!(script.contains("2560"), "missing width in script");
1209        assert!(script.contains("1440"), "missing height in script");
1210    }
1211
1212    #[test]
1213    fn injection_script_contains_timezone() {
1214        let fp = Fingerprint {
1215            timezone: "Europe/Berlin".to_string(),
1216            ..Fingerprint::default()
1217        };
1218        let script = fp.injection_script();
1219        assert!(script.contains("Europe/Berlin"), "timezone missing");
1220    }
1221
1222    #[test]
1223    fn injection_script_contains_canvas_noise_when_enabled() {
1224        let fp = Fingerprint {
1225            canvas_noise: true,
1226            ..Fingerprint::default()
1227        };
1228        let script = fp.injection_script();
1229        assert!(
1230            script.contains("getImageData"),
1231            "canvas noise block missing"
1232        );
1233    }
1234
1235    #[test]
1236    fn injection_script_omits_canvas_noise_when_disabled() {
1237        let fp = Fingerprint {
1238            canvas_noise: false,
1239            ..Fingerprint::default()
1240        };
1241        let script = fp.injection_script();
1242        assert!(
1243            !script.contains("getImageData"),
1244            "canvas noise should be absent"
1245        );
1246    }
1247
1248    #[test]
1249    fn injection_script_contains_webgl_vendor() {
1250        let fp = Fingerprint {
1251            webgl_vendor: Some("TestVendor".to_string()),
1252            webgl_renderer: Some("TestRenderer".to_string()),
1253            canvas_noise: false,
1254            ..Fingerprint::default()
1255        };
1256        let script = fp.injection_script();
1257        assert!(script.contains("TestVendor"), "WebGL vendor missing");
1258        assert!(script.contains("TestRenderer"), "WebGL renderer missing");
1259    }
1260
1261    #[test]
1262    fn inject_fingerprint_fn_equals_method() {
1263        let fp = Fingerprint::default();
1264        assert_eq!(inject_fingerprint(&fp), fp.injection_script());
1265    }
1266
1267    #[test]
1268    fn from_profile_returns_profile_fingerprint() {
1269        let profile = FingerprintProfile::new("test".to_string());
1270        let fp = Fingerprint::from_profile(&profile);
1271        assert_eq!(fp.user_agent, profile.fingerprint.user_agent);
1272    }
1273
1274    #[test]
1275    fn script_is_wrapped_in_iife() {
1276        let script = Fingerprint::default().injection_script();
1277        assert!(script.starts_with("(function()"), "must start with IIFE");
1278        assert!(script.ends_with("})();"), "must end with IIFE call");
1279    }
1280
1281    #[test]
1282    fn rng_produces_distinct_values_for_different_steps() {
1283        let seed = 0xdead_beef_cafe_babe_u64;
1284        let v1 = rng(seed, 1);
1285        let v2 = rng(seed, 2);
1286        let v3 = rng(seed, 3);
1287        assert_ne!(v1, v2);
1288        assert_ne!(v2, v3);
1289    }
1290
1291    // ── T08 — DeviceProfile / BrowserKind / from_device_profile tests ─────────
1292
1293    #[test]
1294    fn device_profile_windows_is_consistent() {
1295        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 42);
1296        assert_eq!(fp.platform, "Win32");
1297        assert!(fp.user_agent.contains("Windows NT"), "UA must be Windows");
1298        assert!(!fp.fonts.is_empty(), "Windows profile must have fonts");
1299        assert!(
1300            fp.validate_consistency().is_empty(),
1301            "must pass consistency"
1302        );
1303    }
1304
1305    #[test]
1306    fn device_profile_mac_is_consistent() {
1307        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
1308        assert_eq!(fp.platform, "MacIntel");
1309        assert!(
1310            fp.user_agent.contains("Mac OS X"),
1311            "UA must be macOS: {}",
1312            fp.user_agent
1313        );
1314        assert!(!fp.fonts.is_empty(), "Mac profile must have fonts");
1315        assert!(
1316            fp.validate_consistency().is_empty(),
1317            "must pass consistency"
1318        );
1319    }
1320
1321    #[test]
1322    fn device_profile_linux_is_consistent() {
1323        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopLinux, 42);
1324        assert_eq!(fp.platform, "Linux x86_64");
1325        assert!(fp.user_agent.contains("Linux"), "UA must be Linux");
1326        assert!(!fp.fonts.is_empty(), "Linux profile must have fonts");
1327        assert!(
1328            fp.validate_consistency().is_empty(),
1329            "must pass consistency"
1330        );
1331    }
1332
1333    #[test]
1334    fn device_profile_android_is_mobile() {
1335        let fp = Fingerprint::from_device_profile(DeviceProfile::MobileAndroid, 42);
1336        assert!(
1337            fp.platform.starts_with("Linux"),
1338            "Android platform should be Linux-based"
1339        );
1340        assert!(
1341            fp.user_agent.contains("Android") || fp.user_agent.contains("Firefox"),
1342            "Android UA mismatch: {}",
1343            fp.user_agent
1344        );
1345        assert!(!fp.fonts.is_empty());
1346        assert!(DeviceProfile::MobileAndroid.is_mobile());
1347    }
1348
1349    #[test]
1350    fn device_profile_ios_is_mobile() {
1351        let fp = Fingerprint::from_device_profile(DeviceProfile::MobileIOS, 42);
1352        assert_eq!(fp.platform, "iPhone");
1353        assert!(
1354            fp.user_agent.contains("iPhone"),
1355            "iOS UA must contain iPhone"
1356        );
1357        assert!(!fp.fonts.is_empty());
1358        assert!(DeviceProfile::MobileIOS.is_mobile());
1359    }
1360
1361    #[test]
1362    fn desktop_profiles_are_not_mobile() {
1363        assert!(!DeviceProfile::DesktopWindows.is_mobile());
1364        assert!(!DeviceProfile::DesktopMac.is_mobile());
1365        assert!(!DeviceProfile::DesktopLinux.is_mobile());
1366    }
1367
1368    #[test]
1369    fn browser_kind_ios_always_safari() {
1370        for seed in [0u64, 1, 42, 999, u64::MAX] {
1371            assert_eq!(
1372                BrowserKind::for_device(DeviceProfile::MobileIOS, seed),
1373                BrowserKind::Safari,
1374                "iOS must always return Safari (seed={seed})"
1375            );
1376        }
1377    }
1378
1379    #[test]
1380    fn device_profile_random_weighted_distribution() {
1381        // Run 1000 samples and verify Windows dominates (expect ≥50%)
1382        let windows_count = (0u64..1000)
1383            .filter(|&i| {
1384                DeviceProfile::random_weighted(i * 13 + 7) == DeviceProfile::DesktopWindows
1385            })
1386            .count();
1387        assert!(
1388            windows_count >= 500,
1389            "Expected ≥50% Windows, got {windows_count}/1000"
1390        );
1391    }
1392
1393    #[test]
1394    fn validate_consistency_catches_platform_ua_mismatch() {
1395        let fp = Fingerprint {
1396            platform: "Win32".to_string(),
1397            user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
1398                         AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
1399                .to_string(),
1400            ..Fingerprint::default()
1401        };
1402        let issues = fp.validate_consistency();
1403        assert!(!issues.is_empty(), "should detect Win32+Mac UA mismatch");
1404    }
1405
1406    #[test]
1407    fn validate_consistency_catches_platform_font_mismatch() {
1408        let fp = Fingerprint {
1409            platform: "MacIntel".to_string(),
1410            fonts: vec!["Segoe UI".to_string(), "Calibri".to_string()],
1411            ..Fingerprint::default()
1412        };
1413        let issues = fp.validate_consistency();
1414        assert!(
1415            !issues.is_empty(),
1416            "should detect MacIntel + Windows fonts mismatch"
1417        );
1418    }
1419
1420    #[test]
1421    fn validate_consistency_passes_for_default() {
1422        let fp = Fingerprint::default();
1423        assert!(fp.validate_consistency().is_empty());
1424    }
1425
1426    #[test]
1427    fn fingerprint_profile_random_weighted_has_fonts() {
1428        let profile = FingerprintProfile::random_weighted("sess-1".to_string());
1429        assert_eq!(profile.name, "sess-1");
1430        assert!(!profile.fingerprint.fonts.is_empty());
1431        assert!(profile.fingerprint.validate_consistency().is_empty());
1432    }
1433
1434    #[test]
1435    fn from_device_profile_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
1436        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 123);
1437        let json = serde_json::to_string(&fp)?;
1438        let back: Fingerprint = serde_json::from_str(&json)?;
1439        assert_eq!(back.platform, fp.platform);
1440        assert_eq!(back.fonts, fp.fonts);
1441        Ok(())
1442    }
1443
1444    // ─── Property-based tests (proptest) ──────────────────────────────────────
1445
1446    proptest::proptest! {
1447        /// For any seed, a device-profile fingerprint must pass `validate_consistency()`.
1448        #[test]
1449        fn prop_seeded_fingerprint_always_consistent(seed in 0u64..10_000) {
1450            let profile = DeviceProfile::random_weighted(seed);
1451            let fp = Fingerprint::from_device_profile(profile, seed);
1452            let issues = fp.validate_consistency();
1453            proptest::prop_assert!(
1454                issues.is_empty(),
1455                "validate_consistency() failed for seed {seed}: {issues:?}"
1456            );
1457        }
1458
1459        /// Hardware concurrency must always be in [1, 32].
1460        #[test]
1461        fn prop_hardware_concurrency_is_sensible(_seed in 0u64..10_000) {
1462            let fp = Fingerprint::random();
1463            proptest::prop_assert!(
1464                fp.hardware_concurrency >= 1 && fp.hardware_concurrency <= 32,
1465                "hardware_concurrency {} out of [1,32]", fp.hardware_concurrency
1466            );
1467        }
1468
1469        /// Device memory must be in the valid JS set {4, 8, 16} (gb as reported to JS).
1470        #[test]
1471        fn prop_device_memory_is_valid_value(_seed in 0u64..10_000) {
1472            let fp = Fingerprint::random();
1473            let valid: &[u32] = &[4, 8, 16];
1474            proptest::prop_assert!(
1475                valid.contains(&fp.device_memory),
1476                "device_memory {} is not a valid value", fp.device_memory
1477            );
1478        }
1479
1480        /// Screen dimensions must be plausible for a real monitor.
1481        #[test]
1482        fn prop_screen_dimensions_are_plausible(_seed in 0u64..10_000) {
1483            let fp = Fingerprint::random();
1484            let (w, h) = fp.screen_resolution;
1485            proptest::prop_assert!((320..=7680).contains(&w));
1486            proptest::prop_assert!((240..=4320).contains(&h));
1487        }
1488
1489        /// FingerprintProfile::random_weighted must always pass consistency.
1490        #[test]
1491        fn prop_fingerprint_profile_passes_consistency(name in "[a-z][a-z0-9]{0,31}") {
1492            let profile = FingerprintProfile::random_weighted(name.clone());
1493            let issues = profile.fingerprint.validate_consistency();
1494            proptest::prop_assert!(
1495                issues.is_empty(),
1496                "FingerprintProfile for '{name}' has issues: {issues:?}"
1497            );
1498        }
1499
1500        /// Injection script is always non-empty and mentions navigator.
1501        #[test]
1502        fn prop_injection_script_non_empty(_seed in 0u64..10_000) {
1503            let fp = Fingerprint::random();
1504            let script = inject_fingerprint(&fp);
1505            proptest::prop_assert!(!script.is_empty());
1506            proptest::prop_assert!(script.contains("navigator"));
1507        }
1508    }
1509}