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_or(0x5a5a_5a5a_5a5a_5a5a, |d| {
306                d.as_secs() ^ u64::from(d.subsec_nanos())
307            });
308
309        let res = pick(SCREEN_RESOLUTIONS, rng(seed, 1));
310        let tz = pick(TIMEZONES, rng(seed, 2));
311        let lang = pick(LANGUAGES, rng(seed, 3));
312        let hw = pick(HARDWARE_CONCURRENCY, rng(seed, 4));
313        let dm = pick(DEVICE_MEMORY, rng(seed, 5));
314        let (wv, wr, platform) = pick(WEBGL_PROFILES, rng(seed, 6));
315
316        let user_agent = if platform == "Win32" {
317            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
318             AppleWebKit/537.36 (KHTML, like Gecko) \
319             Chrome/120.0.0.0 Safari/537.36"
320                .to_string()
321        } else {
322            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
323             AppleWebKit/537.36 (KHTML, like Gecko) \
324             Chrome/120.0.0.0 Safari/537.36"
325                .to_string()
326        };
327
328        let fonts: Vec<String> = if platform == "Win32" {
329            WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect()
330        } else {
331            MACOS_FONTS.iter().map(|s| (*s).to_string()).collect()
332        };
333
334        Self {
335            user_agent,
336            screen_resolution: res,
337            timezone: tz.to_string(),
338            language: lang.to_string(),
339            platform: platform.to_string(),
340            hardware_concurrency: hw,
341            device_memory: dm,
342            webgl_vendor: Some(wv.to_string()),
343            webgl_renderer: Some(wr.to_string()),
344            canvas_noise: true,
345            fonts,
346        }
347    }
348
349    /// Clone a fingerprint from a [`FingerprintProfile`].
350    ///
351    /// # Example
352    ///
353    /// ```
354    /// use stygian_browser::fingerprint::{Fingerprint, FingerprintProfile};
355    ///
356    /// let profile = FingerprintProfile::new("test".to_string());
357    /// let fp = Fingerprint::from_profile(&profile);
358    /// assert!(!fp.user_agent.is_empty());
359    /// ```
360    pub fn from_profile(profile: &FingerprintProfile) -> Self {
361        profile.fingerprint.clone()
362    }
363
364    /// Generate a fingerprint consistent with a specific [`DeviceProfile`].
365    ///
366    /// All properties — user agent, platform, GPU, fonts — are internally
367    /// consistent.  A Mac profile will never carry a Windows GPU, for example.
368    ///
369    /// # Example
370    ///
371    /// ```
372    /// use stygian_browser::fingerprint::{Fingerprint, DeviceProfile};
373    ///
374    /// let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
375    /// assert_eq!(fp.platform, "MacIntel");
376    /// assert!(!fp.fonts.is_empty());
377    /// ```
378    pub fn from_device_profile(device: DeviceProfile, seed: u64) -> Self {
379        match device {
380            DeviceProfile::DesktopWindows => Self::for_windows(seed),
381            DeviceProfile::DesktopMac => Self::for_mac(seed),
382            DeviceProfile::DesktopLinux => Self::for_linux(seed),
383            DeviceProfile::MobileAndroid => Self::for_android(seed),
384            DeviceProfile::MobileIOS => Self::for_ios(seed),
385        }
386    }
387
388    /// Check that all fingerprint fields are internally consistent.
389    ///
390    /// Returns a `Vec<String>` of human-readable inconsistency descriptions.
391    /// An empty vec means the fingerprint passes every check.
392    ///
393    /// # Example
394    ///
395    /// ```
396    /// use stygian_browser::fingerprint::Fingerprint;
397    ///
398    /// let fp = Fingerprint::default();
399    /// assert!(fp.validate_consistency().is_empty());
400    /// ```
401    pub fn validate_consistency(&self) -> Vec<String> {
402        let mut issues = Vec::new();
403
404        // UA / platform cross-check
405        if self.platform == "Win32" && self.user_agent.contains("Mac OS X") {
406            issues.push("Win32 platform but user-agent says Mac OS X".to_string());
407        }
408        if self.platform == "MacIntel" && self.user_agent.contains("Windows NT") {
409            issues.push("MacIntel platform but user-agent says Windows NT".to_string());
410        }
411        if self.platform.starts_with("Linux") && self.user_agent.contains("Windows NT") {
412            issues.push("Linux platform but user-agent says Windows NT".to_string());
413        }
414
415        // WebGL vendor / platform cross-check
416        if let Some(vendor) = &self.webgl_vendor {
417            if (self.platform == "Win32" || self.platform == "MacIntel")
418                && (vendor.contains("Qualcomm")
419                    || vendor.contains("Adreno")
420                    || vendor.contains("Mali"))
421            {
422                issues.push(format!(
423                    "Desktop platform '{}' has mobile GPU vendor '{vendor}'",
424                    self.platform
425                ));
426            }
427            if self.platform == "Win32" && vendor.starts_with("Apple") {
428                issues.push(format!("Win32 platform has Apple GPU vendor '{vendor}'"));
429            }
430        }
431
432        // Font / platform cross-check (only when fonts are populated)
433        if !self.fonts.is_empty() {
434            let has_win_exclusive = self
435                .fonts
436                .iter()
437                .any(|f| matches!(f.as_str(), "Segoe UI" | "Calibri" | "Consolas" | "Tahoma"));
438            let has_mac_exclusive = self.fonts.iter().any(|f| {
439                matches!(
440                    f.as_str(),
441                    "Lucida Grande" | "Avenir" | "Optima" | "Futura" | "Baskerville"
442                )
443            });
444            let has_linux_exclusive = self.fonts.iter().any(|f| {
445                matches!(
446                    f.as_str(),
447                    "DejaVu Sans" | "Liberation Sans" | "Ubuntu" | "FreeMono"
448                )
449            });
450
451            if self.platform == "MacIntel" && has_win_exclusive {
452                issues.push("MacIntel platform has Windows-exclusive fonts".to_string());
453            }
454            if self.platform == "Win32" && has_mac_exclusive {
455                issues.push("Win32 platform has macOS-exclusive fonts".to_string());
456            }
457            if self.platform == "Win32" && has_linux_exclusive {
458                issues.push("Win32 platform has Linux-exclusive fonts".to_string());
459            }
460        }
461
462        issues
463    }
464
465    // ── Private per-OS fingerprint builders ───────────────────────────────────
466
467    fn for_windows(seed: u64) -> Self {
468        let browser = BrowserKind::for_device(DeviceProfile::DesktopWindows, seed);
469        let user_agent = match browser {
470            BrowserKind::Chrome | BrowserKind::Safari => {
471                let ver = pick(CHROME_VERSIONS, rng(seed, 10));
472                format!(
473                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
474                     AppleWebKit/537.36 (KHTML, like Gecko) \
475                     Chrome/{ver}.0.0.0 Safari/537.36"
476                )
477            }
478            BrowserKind::Edge => {
479                let ver = pick(EDGE_VERSIONS, rng(seed, 10));
480                format!(
481                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
482                     AppleWebKit/537.36 (KHTML, like Gecko) \
483                     Chrome/{ver}.0.0.0 Safari/537.36 Edg/{ver}.0.0.0"
484                )
485            }
486            BrowserKind::Firefox => {
487                let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
488                format!(
489                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{ver}.0) \
490                     Gecko/20100101 Firefox/{ver}.0"
491                )
492            }
493        };
494
495        let (webgl_vendor, webgl_renderer) = pick(WINDOWS_WEBGL_PROFILES, rng(seed, 7));
496        let fonts = WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect();
497
498        Self {
499            user_agent,
500            screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
501            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
502            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
503            platform: "Win32".to_string(),
504            hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
505            device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
506            webgl_vendor: Some(webgl_vendor.to_string()),
507            webgl_renderer: Some(webgl_renderer.to_string()),
508            canvas_noise: true,
509            fonts,
510        }
511    }
512
513    fn for_mac(seed: u64) -> Self {
514        let browser = BrowserKind::for_device(DeviceProfile::DesktopMac, seed);
515        let user_agent = match browser {
516            BrowserKind::Chrome | BrowserKind::Edge => {
517                let ver = pick(CHROME_VERSIONS, rng(seed, 10));
518                format!(
519                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
520                     AppleWebKit/537.36 (KHTML, like Gecko) \
521                     Chrome/{ver}.0.0.0 Safari/537.36"
522                )
523            }
524            BrowserKind::Safari => {
525                let ver = pick(SAFARI_VERSIONS, rng(seed, 10));
526                format!(
527                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
528                     AppleWebKit/605.1.15 (KHTML, like Gecko) \
529                     Version/{ver} Safari/605.1.15"
530                )
531            }
532            BrowserKind::Firefox => {
533                let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
534                format!(
535                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:{ver}.0) \
536                     Gecko/20100101 Firefox/{ver}.0"
537                )
538            }
539        };
540
541        let (webgl_vendor, webgl_renderer) = pick(MACOS_WEBGL_PROFILES, rng(seed, 7));
542        let fonts = MACOS_FONTS.iter().map(|s| (*s).to_string()).collect();
543
544        Self {
545            user_agent,
546            screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
547            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
548            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
549            platform: "MacIntel".to_string(),
550            hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
551            device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
552            webgl_vendor: Some(webgl_vendor.to_string()),
553            webgl_renderer: Some(webgl_renderer.to_string()),
554            canvas_noise: true,
555            fonts,
556        }
557    }
558
559    fn for_linux(seed: u64) -> Self {
560        let browser = BrowserKind::for_device(DeviceProfile::DesktopLinux, seed);
561        let user_agent = if browser == BrowserKind::Firefox {
562            let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
563            format!(
564                "Mozilla/5.0 (X11; Linux x86_64; rv:{ver}.0) \
565                 Gecko/20100101 Firefox/{ver}.0"
566            )
567        } else {
568            let ver = pick(CHROME_VERSIONS, rng(seed, 10));
569            format!(
570                "Mozilla/5.0 (X11; Linux x86_64) \
571                 AppleWebKit/537.36 (KHTML, like Gecko) \
572                 Chrome/{ver}.0.0.0 Safari/537.36"
573            )
574        };
575
576        let fonts = LINUX_FONTS.iter().map(|s| (*s).to_string()).collect();
577
578        Self {
579            user_agent,
580            screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
581            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
582            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
583            platform: "Linux x86_64".to_string(),
584            hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
585            device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
586            webgl_vendor: Some("Mesa/X.org".to_string()),
587            webgl_renderer: Some("llvmpipe (LLVM 15.0.7, 256 bits)".to_string()),
588            canvas_noise: true,
589            fonts,
590        }
591    }
592
593    fn for_android(seed: u64) -> Self {
594        let browser = BrowserKind::for_device(DeviceProfile::MobileAndroid, seed);
595        let user_agent = if browser == BrowserKind::Firefox {
596            let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
597            format!(
598                "Mozilla/5.0 (Android 14; Mobile; rv:{ver}.0) \
599                 Gecko/20100101 Firefox/{ver}.0"
600            )
601        } else {
602            let ver = pick(CHROME_VERSIONS, rng(seed, 10));
603            format!(
604                "Mozilla/5.0 (Linux; Android 14; Pixel 7) \
605                 AppleWebKit/537.36 (KHTML, like Gecko) \
606                 Chrome/{ver}.0.6099.144 Mobile Safari/537.36"
607            )
608        };
609
610        let (webgl_vendor, webgl_renderer) = pick(ANDROID_WEBGL_PROFILES, rng(seed, 6));
611        let fonts = MOBILE_ANDROID_FONTS
612            .iter()
613            .map(|s| (*s).to_string())
614            .collect();
615
616        Self {
617            user_agent,
618            screen_resolution: pick(MOBILE_ANDROID_RESOLUTIONS, rng(seed, 1)),
619            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
620            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
621            platform: "Linux armv8l".to_string(),
622            hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
623            device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
624            webgl_vendor: Some(webgl_vendor.to_string()),
625            webgl_renderer: Some(webgl_renderer.to_string()),
626            canvas_noise: true,
627            fonts,
628        }
629    }
630
631    fn for_ios(seed: u64) -> Self {
632        let safari_ver = pick(SAFARI_VERSIONS, rng(seed, 10));
633        let ios_ver = pick(IOS_OS_VERSIONS, rng(seed, 11));
634        let user_agent = format!(
635            "Mozilla/5.0 (iPhone; CPU iPhone OS {ios_ver} like Mac OS X) \
636             AppleWebKit/605.1.15 (KHTML, like Gecko) \
637             Version/{safari_ver} Mobile/15E148 Safari/604.1"
638        );
639
640        let (webgl_vendor, webgl_renderer) = pick(IOS_WEBGL_PROFILES, rng(seed, 6));
641        let fonts = MOBILE_IOS_FONTS.iter().map(|s| (*s).to_string()).collect();
642
643        Self {
644            user_agent,
645            screen_resolution: pick(MOBILE_IOS_RESOLUTIONS, rng(seed, 1)),
646            timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
647            language: pick(LANGUAGES, rng(seed, 3)).to_string(),
648            platform: "iPhone".to_string(),
649            hardware_concurrency: 6,
650            device_memory: 4,
651            webgl_vendor: Some(webgl_vendor.to_string()),
652            webgl_renderer: Some(webgl_renderer.to_string()),
653            canvas_noise: true,
654            fonts,
655        }
656    }
657
658    /// Produce a JavaScript IIFE that spoofs browser fingerprint APIs.
659    ///
660    /// The returned script is intended to be passed to the CDP command
661    /// `Page.addScriptToEvaluateOnNewDocument` so it runs before page JS.
662    ///
663    /// Covers: screen dimensions, timezone, language, hardware concurrency,
664    /// device memory, WebGL parameters, canvas noise, and audio fingerprint
665    /// defence.
666    ///
667    /// # Example
668    ///
669    /// ```
670    /// use stygian_browser::fingerprint::Fingerprint;
671    ///
672    /// let fp = Fingerprint::default();
673    /// let script = fp.injection_script();
674    /// assert!(script.contains("1920"));
675    /// assert!(script.contains("screen"));
676    /// ```
677    pub fn injection_script(&self) -> String {
678        let mut parts = vec![
679            screen_script(self.screen_resolution),
680            timezone_script(&self.timezone),
681            language_script(&self.language, &self.user_agent),
682            hardware_script(self.hardware_concurrency, self.device_memory),
683        ];
684
685        if let (Some(vendor), Some(renderer)) = (&self.webgl_vendor, &self.webgl_renderer) {
686            parts.push(webgl_script(vendor, renderer));
687        }
688
689        if self.canvas_noise {
690            parts.push(canvas_noise_script());
691        }
692
693        parts.push(audio_fingerprint_script());
694        parts.push(connection_spoof_script());
695        parts.push(font_measurement_intercept_script());
696        parts.push(storage_estimate_spoof_script());
697        parts.push(battery_spoof_script());
698        parts.push(plugins_spoof_script());
699
700        format!("(function() {{\n{}\n}})();", parts.join("\n\n"))
701    }
702}
703
704/// A named, reusable fingerprint identity.
705///
706/// # Example
707///
708/// ```
709/// use stygian_browser::fingerprint::FingerprintProfile;
710///
711/// let profile = FingerprintProfile::new("my-session".to_string());
712/// assert_eq!(profile.name, "my-session");
713/// ```
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct FingerprintProfile {
716    /// Human-readable profile name.
717    pub name: String,
718
719    /// The fingerprint data for this profile.
720    pub fingerprint: Fingerprint,
721}
722
723impl FingerprintProfile {
724    /// Create a new profile with a freshly randomised fingerprint.
725    ///
726    /// # Example
727    ///
728    /// ```
729    /// use stygian_browser::fingerprint::FingerprintProfile;
730    ///
731    /// let p = FingerprintProfile::new("bot-1".to_string());
732    /// assert!(!p.fingerprint.user_agent.is_empty());
733    /// ```
734    pub fn new(name: String) -> Self {
735        Self {
736            name,
737            fingerprint: Fingerprint::random(),
738        }
739    }
740
741    /// Create a new profile whose fingerprint is weighted by real-world market share.
742    ///
743    /// Device type (Windows/macOS/Linux) is selected via
744    /// [`DeviceProfile::random_weighted`], then a fully consistent fingerprint
745    /// is generated for that device.  The resulting fingerprint is guaranteed
746    /// to pass [`Fingerprint::validate_consistency`].
747    ///
748    /// # Example
749    ///
750    /// ```
751    /// use stygian_browser::fingerprint::FingerprintProfile;
752    ///
753    /// let profile = FingerprintProfile::random_weighted("session-1".to_string());
754    /// assert!(!profile.fingerprint.fonts.is_empty());
755    /// assert!(profile.fingerprint.validate_consistency().is_empty());
756    /// ```
757    pub fn random_weighted(name: String) -> Self {
758        let seed = std::time::SystemTime::now()
759            .duration_since(std::time::UNIX_EPOCH)
760            .map_or(0x5a5a_5a5a_5a5a_5a5a, |d| {
761                d.as_secs() ^ u64::from(d.subsec_nanos())
762            });
763
764        let device = DeviceProfile::random_weighted(seed);
765        Self {
766            name,
767            fingerprint: Fingerprint::from_device_profile(device, seed),
768        }
769    }
770}
771
772// ── public helper ────────────────────────────────────────────────────────────
773
774/// Return a JavaScript injection script for `fingerprint`.
775///
776/// Equivalent to calling [`Fingerprint::injection_script`] directly; provided
777/// as a standalone function for convenient use without importing the type.
778///
779/// The script should be passed to `Page.addScriptToEvaluateOnNewDocument`.
780///
781/// # Example
782///
783/// ```
784/// use stygian_browser::fingerprint::{Fingerprint, inject_fingerprint};
785///
786/// let fp = Fingerprint::default();
787/// let script = inject_fingerprint(&fp);
788/// assert!(script.starts_with("(function()"));
789/// ```
790pub fn inject_fingerprint(fingerprint: &Fingerprint) -> String {
791    fingerprint.injection_script()
792}
793
794// ── Device profile types ─────────────────────────────────────────────────────
795
796/// Device profile type for consistent fingerprint generation.
797///
798/// Determines the OS, platform string, GPU pool, and font set used when
799/// building a fingerprint via [`Fingerprint::from_device_profile`].
800///
801/// # Example
802///
803/// ```
804/// use stygian_browser::fingerprint::DeviceProfile;
805///
806/// let profile = DeviceProfile::random_weighted(12345);
807/// assert!(!profile.is_mobile());
808/// ```
809#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
810pub enum DeviceProfile {
811    /// Windows 10/11 desktop (≈70% of desktop market share).
812    #[default]
813    DesktopWindows,
814    /// macOS desktop (≈20% of desktop market share).
815    DesktopMac,
816    /// Linux desktop (≈10% of desktop market share).
817    DesktopLinux,
818    /// Android mobile device.
819    MobileAndroid,
820    /// iOS mobile device (iPhone/iPad).
821    MobileIOS,
822}
823
824impl DeviceProfile {
825    /// Select a device profile weighted by real-world desktop market share.
826    ///
827    /// Distribution: Windows 70%, macOS 20%, Linux 10%.
828    ///
829    /// # Example
830    ///
831    /// ```
832    /// use stygian_browser::fingerprint::DeviceProfile;
833    ///
834    /// // Most seeds produce DesktopWindows (70% weight).
835    /// let profile = DeviceProfile::random_weighted(0);
836    /// assert_eq!(profile, DeviceProfile::DesktopWindows);
837    /// ```
838    pub const fn random_weighted(seed: u64) -> Self {
839        let v = rng(seed, 97) % 100;
840        match v {
841            0..=69 => Self::DesktopWindows,
842            70..=89 => Self::DesktopMac,
843            _ => Self::DesktopLinux,
844        }
845    }
846
847    /// Returns `true` for mobile device profiles (Android or iOS).
848    ///
849    /// # Example
850    ///
851    /// ```
852    /// use stygian_browser::fingerprint::DeviceProfile;
853    ///
854    /// assert!(DeviceProfile::MobileAndroid.is_mobile());
855    /// assert!(!DeviceProfile::DesktopWindows.is_mobile());
856    /// ```
857    pub const fn is_mobile(self) -> bool {
858        matches!(self, Self::MobileAndroid | Self::MobileIOS)
859    }
860}
861
862/// Browser kind for user-agent string generation.
863///
864/// Used internally by [`Fingerprint::from_device_profile`] to construct
865/// realistic user-agent strings consistent with the selected device.
866///
867/// # Example
868///
869/// ```
870/// use stygian_browser::fingerprint::{BrowserKind, DeviceProfile};
871///
872/// let kind = BrowserKind::for_device(DeviceProfile::MobileIOS, 42);
873/// assert_eq!(kind, BrowserKind::Safari);
874/// ```
875#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
876pub enum BrowserKind {
877    /// Google Chrome — most common desktop browser.
878    #[default]
879    Chrome,
880    /// Microsoft Edge — Chromium-based, Windows-primary.
881    Edge,
882    /// Apple Safari — macOS/iOS only.
883    Safari,
884    /// Mozilla Firefox.
885    Firefox,
886}
887
888impl BrowserKind {
889    /// Select a browser weighted by market share for the given device profile.
890    ///
891    /// - iOS always returns [`BrowserKind::Safari`] (`WebKit` required).
892    /// - macOS: Chrome 56%, Safari 36%, Firefox 8%.
893    /// - Android: Chrome 90%, Firefox 10%.
894    /// - Windows/Linux: Chrome 65%, Edge 16%, Firefox 19%.
895    ///
896    /// # Example
897    ///
898    /// ```
899    /// use stygian_browser::fingerprint::{BrowserKind, DeviceProfile};
900    ///
901    /// let kind = BrowserKind::for_device(DeviceProfile::MobileIOS, 0);
902    /// assert_eq!(kind, BrowserKind::Safari);
903    /// ```
904    pub const fn for_device(device: DeviceProfile, seed: u64) -> Self {
905        match device {
906            DeviceProfile::MobileIOS => Self::Safari,
907            DeviceProfile::MobileAndroid => {
908                let v = rng(seed, 201) % 100;
909                if v < 90 { Self::Chrome } else { Self::Firefox }
910            }
911            DeviceProfile::DesktopMac => {
912                let v = rng(seed, 201) % 100;
913                match v {
914                    0..=55 => Self::Chrome,
915                    56..=91 => Self::Safari,
916                    _ => Self::Firefox,
917                }
918            }
919            _ => {
920                // Windows / Linux
921                let v = rng(seed, 201) % 100;
922                match v {
923                    0..=64 => Self::Chrome,
924                    65..=80 => Self::Edge,
925                    _ => Self::Firefox,
926                }
927            }
928        }
929    }
930}
931
932// ── JavaScript generation helpers ────────────────────────────────────────────
933
934fn screen_script((width, height): (u32, u32)) -> String {
935    // availHeight leaves ~40 px for a taskbar / dock.
936    let avail_height = height.saturating_sub(40);
937    // availLeft/availTop: on Windows the taskbar is usually at the bottom so
938    // both are 0. Spoofing to 0 matches the most common real-device value and
939    // avoids the headless default which Turnstile checks explicitly.
940    format!(
941        r"  // Screen dimensions
942  const _defineScreen = (prop, val) =>
943    Object.defineProperty(screen, prop, {{ get: () => val, configurable: false }});
944  _defineScreen('width',       {width});
945  _defineScreen('height',      {height});
946  _defineScreen('availWidth',  {width});
947  _defineScreen('availHeight', {avail_height});
948  _defineScreen('availLeft',   0);
949  _defineScreen('availTop',    0);
950  _defineScreen('colorDepth',  24);
951  _defineScreen('pixelDepth',  24);
952  // outerWidth/outerHeight: headless Chrome may return 0; spoof to viewport size.
953  try {{
954    Object.defineProperty(window, 'outerWidth',  {{ get: () => {width},  configurable: true }});
955    Object.defineProperty(window, 'outerHeight', {{ get: () => {height}, configurable: true }});
956  }} catch(_) {{}}"
957    )
958}
959
960fn timezone_script(timezone: &str) -> String {
961    format!(
962        r"  // Timezone via Intl.DateTimeFormat
963  const _origResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
964  Intl.DateTimeFormat.prototype.resolvedOptions = function() {{
965    const opts = _origResolvedOptions.apply(this, arguments);
966    opts.timeZone = {timezone:?};
967    return opts;
968  }};"
969    )
970}
971
972fn language_script(language: &str, user_agent: &str) -> String {
973    // Build a plausible accept-languages list from the primary tag.
974    let primary = language.split('-').next().unwrap_or("en");
975    format!(
976        r"  // Language + userAgent
977  Object.defineProperty(navigator, 'language',   {{ get: () => {language:?}, configurable: false }});
978  Object.defineProperty(navigator, 'languages',  {{ get: () => Object.freeze([{language:?}, {primary:?}]), configurable: false }});
979  Object.defineProperty(navigator, 'userAgent',  {{ get: () => {user_agent:?}, configurable: false }});"
980    )
981}
982
983fn hardware_script(concurrency: u32, memory: u32) -> String {
984    format!(
985        r"  // Hardware concurrency + device memory
986  Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {concurrency}, configurable: false }});
987  Object.defineProperty(navigator, 'deviceMemory',        {{ get: () => {memory}, configurable: false }});"
988    )
989}
990
991fn webgl_script(vendor: &str, renderer: &str) -> String {
992    format!(
993        r"  // WebGL vendor + renderer
994  (function() {{
995    const _getContext = HTMLCanvasElement.prototype.getContext;
996    HTMLCanvasElement.prototype.getContext = function(type, attrs) {{
997      const ctx = _getContext.call(this, type, attrs);
998      if (!ctx) return ctx;
999      if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {{
1000        const _getParam = ctx.getParameter.bind(ctx);
1001        ctx.getParameter = function(param) {{
1002          if (param === 0x1F00) return {vendor:?};    // GL_VENDOR
1003          if (param === 0x1F01) return {renderer:?};  // GL_RENDERER
1004          return _getParam(param);
1005        }};
1006      }}
1007      return ctx;
1008    }};
1009  }})();"
1010    )
1011}
1012
1013fn canvas_noise_script() -> String {
1014    r"  // Canvas noise: flip lowest bit of R/G/B channels to defeat pixel readback
1015  (function() {
1016    const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
1017    CanvasRenderingContext2D.prototype.getImageData = function() {
1018      const id = _getImageData.apply(this, arguments);
1019      const d  = id.data;
1020      for (let i = 0; i < d.length; i += 4) {
1021        d[i]     ^= 1;
1022        d[i + 1] ^= 1;
1023        d[i + 2] ^= 1;
1024      }
1025      return id;
1026    };
1027  })();"
1028        .to_string()
1029}
1030
1031fn audio_fingerprint_script() -> String {
1032    r"  // Audio fingerprint defence: add sub-epsilon noise to frequency data
1033  (function() {
1034    if (typeof AnalyserNode === 'undefined') return;
1035    const _getFloatFreq = AnalyserNode.prototype.getFloatFrequencyData;
1036    AnalyserNode.prototype.getFloatFrequencyData = function(arr) {
1037      _getFloatFreq.apply(this, arguments);
1038      for (let i = 0; i < arr.length; i++) {
1039        arr[i] += (Math.random() - 0.5) * 1e-7;
1040      }
1041    };
1042  })();"
1043        .to_string()
1044}
1045
1046/// Spoof the `NetworkInformation` API (`navigator.connection`) so headless sessions
1047/// report a realistic `WiFi` connection rather than the undefined/zero-RTT default
1048/// that Akamai Bot Manager v3 uses as a headless indicator.
1049fn connection_spoof_script() -> String {
1050    // _seed is 0–996; derived from performance.timeOrigin (epoch ms with sub-ms
1051    // precision) so the RTT/downlink values vary realistically across sessions.
1052    r"  // NetworkInformation API spoof (navigator.connection)
1053  (function() {
1054    const _seed = Math.floor(performance.timeOrigin % 997);
1055    const conn = {
1056      rtt:           50 + _seed % 100,
1057      downlink:      5 + _seed % 15,
1058      effectiveType: '4g',
1059      type:          'wifi',
1060      saveData:      false,
1061      onchange:      null,
1062      ontypechange:  null,
1063      addEventListener:    function() {},
1064      removeEventListener: function() {},
1065      dispatchEvent:       function() { return true; },
1066    };
1067    try {
1068      Object.defineProperty(navigator, 'connection', {
1069        get: () => conn,
1070        enumerable: true,
1071        configurable: false,
1072      });
1073    } catch (_) {}
1074  })();"
1075        .to_string()
1076}
1077
1078/// Intercept `getBoundingClientRect` on hidden font-probe elements.
1079///
1080/// Turnstile creates a hidden `<div>`, renders a string, and measures
1081/// `getBoundingClientRect` to verify the font physically renders.  In headless
1082/// Chrome the sandbox may not have the font installed, so the browser returns a
1083/// zero-size rect.  This intercept detects zero-size rects on elements that are
1084/// hidden (`visibility:hidden` or `aria-hidden`) with absolute/fixed positioning
1085/// (the canonical font-probe pattern) and returns plausible non-zero dimensions
1086/// drawn from a seeded deterministic jitter so the same call always returns the
1087/// same value within a session.
1088fn font_measurement_intercept_script() -> String {
1089    r"  // getBoundingClientRect font-probe intercept (Turnstile Layer 1)
1090  (function() {
1091    const _origGBCR = Element.prototype.getBoundingClientRect;
1092    const _seed = Math.floor(performance.timeOrigin % 9973);
1093    function _jitter(base, range) {
1094      return base + ((_seed * 1103515245 + 12345) & 0x7fffffff) % range;
1095    }
1096    Element.prototype.getBoundingClientRect = function() {
1097      const rect = _origGBCR.call(this);
1098      // Only intercept zero-size rects on hidden probe elements (the font-
1099      // measurement pattern: position absolute/fixed, visibility hidden).
1100      if (rect.width === 0 && rect.height === 0) {
1101        const st = window.getComputedStyle(this);
1102        const vis = st.getPropertyValue('visibility');
1103        const pos = st.getPropertyValue('position');
1104        const ariaHidden = this.getAttribute('aria-hidden');
1105        if ((vis === 'hidden' || ariaHidden === 'true') &&
1106            (pos === 'absolute' || pos === 'fixed')) {
1107          const w = _jitter(10, 8);
1108          const h = _jitter(14, 4);
1109          return new DOMRect(0, 0, w, h);
1110        }
1111      }
1112      return rect;
1113    };
1114  })();"
1115        .to_string()
1116}
1117
1118/// Spoof `navigator.storage.estimate()` to return realistic quota/usage values.
1119///
1120/// Headless Chrome returns a very low `quota` (typically 60–120 MB) vs a real
1121/// browser profile which accumulates gigabytes of quota.  Turnstile explicitly
1122/// reads `quota` and `usage` to compare against expected real-profile ranges.
1123fn storage_estimate_spoof_script() -> String {
1124    r"  // navigator.storage.estimate() spoof (Turnstile Layer 1 — storage)
1125  (function() {
1126    if (!navigator.storage || typeof navigator.storage.estimate !== 'function') return;
1127    const _origEstimate = navigator.storage.estimate.bind(navigator.storage);
1128    const _seed = Math.floor(performance.timeOrigin % 9973);
1129    // Realistic Chrome profile: ~250 GB quota, small stable usage.
1130    const quota = (240 + _seed % 20) * 1073741824;
1131    const usage = (5  + _seed % 10) * 1048576;
1132    navigator.storage.estimate = function() {
1133      return _origEstimate().then(function(result) {
1134                return Object.assign({}, result, {
1135                    quota: quota,
1136                    usage: usage
1137                });
1138      });
1139    };
1140  })();"
1141        .to_string()
1142}
1143
1144/// Normalize `navigator.getBattery()` away from the suspicious fully-charged
1145/// headless default (`level: 1.0`, `charging: true`) that many bot detectors
1146/// flag.  Resolves to a realistic mid-charge, discharging state.
1147/// Spoof `navigator.plugins` and `navigator.mimeTypes`.
1148///
1149/// An empty `PluginArray` (length 0) is the single most-flagged headless
1150/// indicator on services like pixelscan.net and Akamai Bot Manager.  Real
1151/// Chrome always exposes at least the built-in PDF Viewer plugin.
1152fn plugins_spoof_script() -> String {
1153    r"  // navigator.plugins / mimeTypes — empty array = instant headless flag
1154  (function() {
1155    // Build minimal objects that survive instanceof checks.
1156    var mime0 = { type: 'application/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1157    var mime1 = { type: 'text/pdf',        description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1158    var pdfPlugin = {
1159      name: 'PDF Viewer',
1160      description: 'Portable Document Format',
1161      filename: 'internal-pdf-viewer',
1162      length: 2,
1163      0: mime0, 1: mime1,
1164      item: function(i) { return [mime0, mime1][i] || null; },
1165      namedItem: function(n) {
1166        if (n === 'application/pdf') return mime0;
1167        if (n === 'text/pdf')        return mime1;
1168        return null;
1169      },
1170    };
1171    mime0.enabledPlugin = pdfPlugin;
1172    mime1.enabledPlugin = pdfPlugin;
1173
1174    var fakePlugins = {
1175      length: 1,
1176      0: pdfPlugin,
1177      item: function(i) { return i === 0 ? pdfPlugin : null; },
1178      namedItem: function(n) { return n === 'PDF Viewer' ? pdfPlugin : null; },
1179      refresh: function() {},
1180    };
1181    var fakeMimes = {
1182      length: 2,
1183      0: mime0, 1: mime1,
1184      item: function(i) { return [mime0, mime1][i] || null; },
1185      namedItem: function(n) {
1186        if (n === 'application/pdf') return mime0;
1187        if (n === 'text/pdf')        return mime1;
1188        return null;
1189      },
1190    };
1191
1192    try {
1193      Object.defineProperty(navigator, 'plugins',   { get: function() { return fakePlugins; }, configurable: false });
1194      Object.defineProperty(navigator, 'mimeTypes', { get: function() { return fakeMimes; },   configurable: false });
1195    } catch(_) {}
1196  })();"
1197        .to_string()
1198}
1199
1200fn battery_spoof_script() -> String {
1201    r"  // Battery API normalization (navigator.getBattery)
1202  (function() {
1203    if (typeof navigator.getBattery !== 'function') return;
1204    const _seed = Math.floor(performance.timeOrigin % 997);
1205    const battery = {
1206      charging:        false,
1207      chargingTime:    Infinity,
1208      dischargingTime: 3600 + _seed * 7,
1209      level:           0.65 + (_seed % 30) / 100,
1210      onchargingchange:        null,
1211      onchargingtimechange:    null,
1212      ondischargingtimechange: null,
1213      onlevelchange:           null,
1214      addEventListener:    function() {},
1215      removeEventListener: function() {},
1216      dispatchEvent:       function() { return true; },
1217    };
1218    navigator.getBattery = function() {
1219      return Promise.resolve(battery);
1220    };
1221  })();"
1222        .to_string()
1223}
1224
1225// ── tests ────────────────────────────────────────────────────────────────────
1226
1227#[cfg(test)]
1228mod tests {
1229    use super::*;
1230
1231    #[test]
1232    fn random_fingerprint_has_valid_ranges() {
1233        let fp = Fingerprint::random();
1234        let (w, h) = fp.screen_resolution;
1235        assert!(
1236            (1280..=3840).contains(&w),
1237            "width {w} out of expected range"
1238        );
1239        assert!(
1240            (768..=2160).contains(&h),
1241            "height {h} out of expected range"
1242        );
1243        assert!(
1244            HARDWARE_CONCURRENCY.contains(&fp.hardware_concurrency),
1245            "hardware_concurrency {} not in pool",
1246            fp.hardware_concurrency
1247        );
1248        assert!(
1249            DEVICE_MEMORY.contains(&fp.device_memory),
1250            "device_memory {} not in pool",
1251            fp.device_memory
1252        );
1253        assert!(
1254            TIMEZONES.contains(&fp.timezone.as_str()),
1255            "timezone {} not in pool",
1256            fp.timezone
1257        );
1258        assert!(
1259            LANGUAGES.contains(&fp.language.as_str()),
1260            "language {} not in pool",
1261            fp.language
1262        );
1263    }
1264
1265    #[test]
1266    fn random_generates_different_values_over_time() {
1267        // Two calls should eventually differ across seeds; at minimum the
1268        // function must not panic and must return valid data.
1269        let fp1 = Fingerprint::random();
1270        let fp2 = Fingerprint::random();
1271        // Both are well-formed whether or not they happen to be equal.
1272        assert!(!fp1.user_agent.is_empty());
1273        assert!(!fp2.user_agent.is_empty());
1274    }
1275
1276    #[test]
1277    fn injection_script_contains_screen_dimensions() {
1278        let fp = Fingerprint {
1279            screen_resolution: (2560, 1440),
1280            ..Fingerprint::default()
1281        };
1282        let script = fp.injection_script();
1283        assert!(script.contains("2560"), "missing width in script");
1284        assert!(script.contains("1440"), "missing height in script");
1285    }
1286
1287    #[test]
1288    fn injection_script_contains_timezone() {
1289        let fp = Fingerprint {
1290            timezone: "Europe/Berlin".to_string(),
1291            ..Fingerprint::default()
1292        };
1293        let script = fp.injection_script();
1294        assert!(script.contains("Europe/Berlin"), "timezone missing");
1295    }
1296
1297    #[test]
1298    fn injection_script_contains_canvas_noise_when_enabled() {
1299        let fp = Fingerprint {
1300            canvas_noise: true,
1301            ..Fingerprint::default()
1302        };
1303        let script = fp.injection_script();
1304        assert!(
1305            script.contains("getImageData"),
1306            "canvas noise block missing"
1307        );
1308    }
1309
1310    #[test]
1311    fn injection_script_omits_canvas_noise_when_disabled() {
1312        let fp = Fingerprint {
1313            canvas_noise: false,
1314            ..Fingerprint::default()
1315        };
1316        let script = fp.injection_script();
1317        assert!(
1318            !script.contains("getImageData"),
1319            "canvas noise should be absent"
1320        );
1321    }
1322
1323    #[test]
1324    fn injection_script_contains_webgl_vendor() {
1325        let fp = Fingerprint {
1326            webgl_vendor: Some("TestVendor".to_string()),
1327            webgl_renderer: Some("TestRenderer".to_string()),
1328            canvas_noise: false,
1329            ..Fingerprint::default()
1330        };
1331        let script = fp.injection_script();
1332        assert!(script.contains("TestVendor"), "WebGL vendor missing");
1333        assert!(script.contains("TestRenderer"), "WebGL renderer missing");
1334    }
1335
1336    #[test]
1337    fn inject_fingerprint_fn_equals_method() {
1338        let fp = Fingerprint::default();
1339        assert_eq!(inject_fingerprint(&fp), fp.injection_script());
1340    }
1341
1342    #[test]
1343    fn from_profile_returns_profile_fingerprint() {
1344        let profile = FingerprintProfile::new("test".to_string());
1345        let fp = Fingerprint::from_profile(&profile);
1346        assert_eq!(fp.user_agent, profile.fingerprint.user_agent);
1347    }
1348
1349    #[test]
1350    fn script_is_wrapped_in_iife() {
1351        let script = Fingerprint::default().injection_script();
1352        assert!(script.starts_with("(function()"), "must start with IIFE");
1353        assert!(script.ends_with("})();"), "must end with IIFE call");
1354    }
1355
1356    #[test]
1357    fn rng_produces_distinct_values_for_different_steps() {
1358        let seed = 0xdead_beef_cafe_babe_u64;
1359        let v1 = rng(seed, 1);
1360        let v2 = rng(seed, 2);
1361        let v3 = rng(seed, 3);
1362        assert_ne!(v1, v2);
1363        assert_ne!(v2, v3);
1364    }
1365
1366    // ── T08 — DeviceProfile / BrowserKind / from_device_profile tests ─────────
1367
1368    #[test]
1369    fn device_profile_windows_is_consistent() {
1370        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 42);
1371        assert_eq!(fp.platform, "Win32");
1372        assert!(fp.user_agent.contains("Windows NT"), "UA must be Windows");
1373        assert!(!fp.fonts.is_empty(), "Windows profile must have fonts");
1374        assert!(
1375            fp.validate_consistency().is_empty(),
1376            "must pass consistency"
1377        );
1378    }
1379
1380    #[test]
1381    fn device_profile_mac_is_consistent() {
1382        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
1383        assert_eq!(fp.platform, "MacIntel");
1384        assert!(
1385            fp.user_agent.contains("Mac OS X"),
1386            "UA must be macOS: {}",
1387            fp.user_agent
1388        );
1389        assert!(!fp.fonts.is_empty(), "Mac profile must have fonts");
1390        assert!(
1391            fp.validate_consistency().is_empty(),
1392            "must pass consistency"
1393        );
1394    }
1395
1396    #[test]
1397    fn device_profile_linux_is_consistent() {
1398        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopLinux, 42);
1399        assert_eq!(fp.platform, "Linux x86_64");
1400        assert!(fp.user_agent.contains("Linux"), "UA must be Linux");
1401        assert!(!fp.fonts.is_empty(), "Linux profile must have fonts");
1402        assert!(
1403            fp.validate_consistency().is_empty(),
1404            "must pass consistency"
1405        );
1406    }
1407
1408    #[test]
1409    fn device_profile_android_is_mobile() {
1410        let fp = Fingerprint::from_device_profile(DeviceProfile::MobileAndroid, 42);
1411        assert!(
1412            fp.platform.starts_with("Linux"),
1413            "Android platform should be Linux-based"
1414        );
1415        assert!(
1416            fp.user_agent.contains("Android") || fp.user_agent.contains("Firefox"),
1417            "Android UA mismatch: {}",
1418            fp.user_agent
1419        );
1420        assert!(!fp.fonts.is_empty());
1421        assert!(DeviceProfile::MobileAndroid.is_mobile());
1422    }
1423
1424    #[test]
1425    fn device_profile_ios_is_mobile() {
1426        let fp = Fingerprint::from_device_profile(DeviceProfile::MobileIOS, 42);
1427        assert_eq!(fp.platform, "iPhone");
1428        assert!(
1429            fp.user_agent.contains("iPhone"),
1430            "iOS UA must contain iPhone"
1431        );
1432        assert!(!fp.fonts.is_empty());
1433        assert!(DeviceProfile::MobileIOS.is_mobile());
1434    }
1435
1436    #[test]
1437    fn desktop_profiles_are_not_mobile() {
1438        assert!(!DeviceProfile::DesktopWindows.is_mobile());
1439        assert!(!DeviceProfile::DesktopMac.is_mobile());
1440        assert!(!DeviceProfile::DesktopLinux.is_mobile());
1441    }
1442
1443    #[test]
1444    fn browser_kind_ios_always_safari() {
1445        for seed in [0u64, 1, 42, 999, u64::MAX] {
1446            assert_eq!(
1447                BrowserKind::for_device(DeviceProfile::MobileIOS, seed),
1448                BrowserKind::Safari,
1449                "iOS must always return Safari (seed={seed})"
1450            );
1451        }
1452    }
1453
1454    #[test]
1455    fn device_profile_random_weighted_distribution() {
1456        // Run 1000 samples and verify Windows dominates (expect ≥50%)
1457        let windows_count = (0u64..1000)
1458            .filter(|&i| {
1459                DeviceProfile::random_weighted(i * 13 + 7) == DeviceProfile::DesktopWindows
1460            })
1461            .count();
1462        assert!(
1463            windows_count >= 500,
1464            "Expected ≥50% Windows, got {windows_count}/1000"
1465        );
1466    }
1467
1468    #[test]
1469    fn validate_consistency_catches_platform_ua_mismatch() {
1470        let fp = Fingerprint {
1471            platform: "Win32".to_string(),
1472            user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
1473                         AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
1474                .to_string(),
1475            ..Fingerprint::default()
1476        };
1477        let issues = fp.validate_consistency();
1478        assert!(!issues.is_empty(), "should detect Win32+Mac UA mismatch");
1479    }
1480
1481    #[test]
1482    fn validate_consistency_catches_platform_font_mismatch() {
1483        let fp = Fingerprint {
1484            platform: "MacIntel".to_string(),
1485            fonts: vec!["Segoe UI".to_string(), "Calibri".to_string()],
1486            ..Fingerprint::default()
1487        };
1488        let issues = fp.validate_consistency();
1489        assert!(
1490            !issues.is_empty(),
1491            "should detect MacIntel + Windows fonts mismatch"
1492        );
1493    }
1494
1495    #[test]
1496    fn validate_consistency_passes_for_default() {
1497        let fp = Fingerprint::default();
1498        assert!(fp.validate_consistency().is_empty());
1499    }
1500
1501    #[test]
1502    fn fingerprint_profile_random_weighted_has_fonts() {
1503        let profile = FingerprintProfile::random_weighted("sess-1".to_string());
1504        assert_eq!(profile.name, "sess-1");
1505        assert!(!profile.fingerprint.fonts.is_empty());
1506        assert!(profile.fingerprint.validate_consistency().is_empty());
1507    }
1508
1509    #[test]
1510    fn from_device_profile_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
1511        let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 123);
1512        let json = serde_json::to_string(&fp)?;
1513        let back: Fingerprint = serde_json::from_str(&json)?;
1514        assert_eq!(back.platform, fp.platform);
1515        assert_eq!(back.fonts, fp.fonts);
1516        Ok(())
1517    }
1518
1519    // ─── Property-based tests (proptest) ──────────────────────────────────────
1520
1521    proptest::proptest! {
1522        /// For any seed, a device-profile fingerprint must pass `validate_consistency()`.
1523        #[test]
1524        fn prop_seeded_fingerprint_always_consistent(seed in 0u64..10_000) {
1525            let profile = DeviceProfile::random_weighted(seed);
1526            let fp = Fingerprint::from_device_profile(profile, seed);
1527            let issues = fp.validate_consistency();
1528            proptest::prop_assert!(
1529                issues.is_empty(),
1530                "validate_consistency() failed for seed {seed}: {issues:?}"
1531            );
1532        }
1533
1534        /// Hardware concurrency must always be in [1, 32].
1535        #[test]
1536        fn prop_hardware_concurrency_is_sensible(_seed in 0u64..10_000) {
1537            let fp = Fingerprint::random();
1538            proptest::prop_assert!(
1539                fp.hardware_concurrency >= 1 && fp.hardware_concurrency <= 32,
1540                "hardware_concurrency {} out of [1,32]", fp.hardware_concurrency
1541            );
1542        }
1543
1544        /// Device memory must be in the valid JS set {4, 8, 16} (gb as reported to JS).
1545        #[test]
1546        fn prop_device_memory_is_valid_value(_seed in 0u64..10_000) {
1547            let fp = Fingerprint::random();
1548            let valid: &[u32] = &[4, 8, 16];
1549            proptest::prop_assert!(
1550                valid.contains(&fp.device_memory),
1551                "device_memory {} is not a valid value", fp.device_memory
1552            );
1553        }
1554
1555        /// Screen dimensions must be plausible for a real monitor.
1556        #[test]
1557        fn prop_screen_dimensions_are_plausible(_seed in 0u64..10_000) {
1558            let fp = Fingerprint::random();
1559            let (w, h) = fp.screen_resolution;
1560            proptest::prop_assert!((320..=7680).contains(&w));
1561            proptest::prop_assert!((240..=4320).contains(&h));
1562        }
1563
1564        /// FingerprintProfile::random_weighted must always pass consistency.
1565        #[test]
1566        fn prop_fingerprint_profile_passes_consistency(name in "[a-z][a-z0-9]{0,31}") {
1567            let profile = FingerprintProfile::random_weighted(name.clone());
1568            let issues = profile.fingerprint.validate_consistency();
1569            proptest::prop_assert!(
1570                issues.is_empty(),
1571                "FingerprintProfile for '{name}' has issues: {issues:?}"
1572            );
1573        }
1574
1575        /// Injection script is always non-empty and mentions navigator.
1576        #[test]
1577        fn prop_injection_script_non_empty(_seed in 0u64..10_000) {
1578            let fp = Fingerprint::random();
1579            let script = inject_fingerprint(&fp);
1580            proptest::prop_assert!(!script.is_empty());
1581            proptest::prop_assert!(script.contains("navigator"));
1582        }
1583    }
1584}