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