Skip to main content

stygian_browser/
fingerprint.rs

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