Skip to main content

hpx_browser/
stealth.rs

1//! Stealth fingerprint profiles for hpx-browser.
2//!
3//! Provides consistent browser identities — UA string, screen, locale,
4//! GPU vendor/renderer, TLS impersonation label — so the engine reports
5//! a coherent "I am Chrome 148 on macOS" surface rather than a default
6//! headless fingerprint.
7
8use serde::{Deserialize, Serialize};
9
10// ── GPU catalog ──────────────────────────────────────────────────────
11
12/// A snapshot of a real GPU's WebGL fingerprint as Chrome exposes it.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GpuProfile {
15    /// `getParameter(VENDOR)` — Chrome always returns "WebKit".
16    pub vendor: String,
17    /// `getParameter(RENDERER)` — Chrome always returns "WebKit WebGL".
18    pub renderer: String,
19    /// `getParameter(VERSION)`.
20    pub version: String,
21    /// `getParameter(SHADING_LANGUAGE_VERSION)`.
22    pub shading_language_version: String,
23    /// `getParameter(UNMASKED_VENDOR_WEBGL)`.
24    pub unmasked_vendor: String,
25    /// `getParameter(UNMASKED_RENDERER_WEBGL)`.
26    pub unmasked_renderer: String,
27    /// `getSupportedExtensions()`.
28    pub extensions: Vec<String>,
29    /// Additional `getParameter()` values keyed by GLenum.
30    pub params: Vec<(u32, serde_json::Value)>,
31    /// `getShaderPrecisionFormat()` values.
32    pub shader_precision: Vec<(u32, u32, [i32; 3])>,
33    /// Distinct WebGL 1.0 surface (version string + extension list).
34    #[serde(default)]
35    pub webgl1: Option<WebGL1Surface>,
36}
37
38/// WebGL 1.0 surface, distinct from the WebGL 2.0 fields on `GpuProfile`.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct WebGL1Surface {
41    pub version: String,
42    pub shading_language_version: String,
43    pub extensions: Vec<String>,
44}
45
46impl Default for GpuProfile {
47    fn default() -> Self {
48        nvidia_rtx_3060_windows()
49    }
50}
51
52// ── Device class ─────────────────────────────────────────────────────
53
54/// Device class driving TLS curve selection, Sec-CH-UA-Mobile, etc.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub enum DeviceClass {
57    #[default]
58    Desktop,
59    MobileAndroid,
60    MobileIOS,
61}
62
63// ── Media device ─────────────────────────────────────────────────────
64
65/// A media device reported by `navigator.mediaDevices.enumerateDevices()`.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct MediaDeviceInfo {
68    pub device_id: String,
69    pub kind: String,
70    pub label: String,
71    pub group_id: String,
72}
73
74// ── StealthProfile ───────────────────────────────────────────────────
75
76/// A complete stealth fingerprint profile.
77///
78/// Start from a preset constructor; to customise, clone a preset, mutate
79/// fields, and call [`StealthProfile::validate`].
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct StealthProfile {
82    // === Identity ===
83    pub user_agent: String,
84    pub browser_name: String,
85    pub browser_version: String,
86    pub os_name: String,
87    pub os_version: String,
88    pub platform: String,
89    pub vendor: String,
90    pub vendor_sub: String,
91    pub product_sub: String,
92    pub app_version: String,
93
94    // === Hardware ===
95    pub screen_width: u32,
96    pub screen_height: u32,
97    pub screen_avail_width: u32,
98    pub screen_avail_height: u32,
99    pub screen_avail_top: u32,
100    pub screen_color_depth: u32,
101    pub device_pixel_ratio: f64,
102    pub cpu_cores: u8,
103    pub device_memory: u8,
104    pub max_touch_points: u8,
105
106    // === GPU / WebGL ===
107    pub webgl_vendor: String,
108    pub webgl_renderer: String,
109    #[serde(default = "default_gpu_profile")]
110    pub gpu_profile: GpuProfile,
111
112    // === Locale ===
113    pub language: String,
114    pub languages: Vec<String>,
115    pub timezone: String,
116
117    // === Client Hints high-entropy values ===
118    #[serde(default = "default_cpu_architecture")]
119    pub cpu_architecture: String,
120    #[serde(default = "default_cpu_bitness")]
121    pub cpu_bitness: String,
122    #[serde(default)]
123    pub platform_version: String,
124    #[serde(default)]
125    pub ua_model: String,
126    #[serde(default)]
127    pub ua_wow64: bool,
128
129    // === Network ===
130    #[serde(default)]
131    pub device_class: DeviceClass,
132    pub tls_impersonate: String,
133    pub connection_effective_type: String,
134    pub connection_rtt: u32,
135    pub connection_downlink: f64,
136
137    // === Plugins ===
138    pub pdf_viewer_enabled: bool,
139    pub plugins_count: u32,
140    pub mime_types_count: u32,
141
142    // === Fingerprint seeds ===
143    pub canvas_seed: u64,
144    pub audio_seed: u64,
145    #[serde(default = "default_audio_sample_rate")]
146    pub audio_sample_rate: u32,
147
148    // === WebAuthn / FedCM ===
149    #[serde(default)]
150    pub has_platform_authenticator: bool,
151    #[serde(default = "default_true")]
152    pub conditional_mediation: bool,
153
154    // === HTTP/3 / QUIC ===
155    #[serde(default)]
156    pub allow_http3: bool,
157
158    // === Media features ===
159    pub prefers_color_scheme: String,
160    pub pointer_type: String,
161    pub hover_capability: String,
162    #[serde(default = "default_color_gamut")]
163    pub color_gamut: String,
164
165    // === Window dimensions ===
166    pub inner_width: u32,
167    pub inner_height: u32,
168    pub outer_width: u32,
169    pub outer_height: u32,
170
171    // === Proxy ===
172    #[serde(default)]
173    pub proxy: Option<String>,
174
175    // === Media devices ===
176    #[serde(default)]
177    pub media_devices: Vec<MediaDeviceInfo>,
178
179    /// Enforce CSP on sub-resource fetches. Defaults to `true`.
180    #[serde(default = "default_true")]
181    pub enforce_csp: bool,
182}
183
184fn default_color_gamut() -> String {
185    "srgb".into()
186}
187fn default_true() -> bool {
188    true
189}
190fn default_gpu_profile() -> GpuProfile {
191    nvidia_rtx_3060_windows()
192}
193fn default_cpu_architecture() -> String {
194    "x86".into()
195}
196fn default_cpu_bitness() -> String {
197    "64".into()
198}
199fn default_audio_sample_rate() -> u32 {
200    44100
201}
202
203impl Default for StealthProfile {
204    fn default() -> Self {
205        chrome_148_windows()
206    }
207}
208
209// ── Validation ───────────────────────────────────────────────────────
210
211impl StealthProfile {
212    /// Validate that all fields are internally consistent.
213    pub fn validate(&self) -> Result<(), Vec<String>> {
214        let mut errors = Vec::new();
215
216        // UA must contain the reduced major version (Chrome) or short version (Firefox)
217        let ua_major = self.browser_version.split('.').next().unwrap_or("");
218        let chrome_form = format!("{ua_major}.0.0.0");
219        let firefox_form = format!("{ua_major}.0");
220        if !self.user_agent.contains(&chrome_form) && !self.user_agent.contains(&firefox_form) {
221            errors.push(format!(
222                "UA '{}' doesn't contain reduced major version '{}' or '{}'",
223                self.user_agent, chrome_form, firefox_form
224            ));
225        }
226
227        // Platform must match OS
228        match self.os_name.as_str() {
229            "Windows" if self.platform != "Win32" => {
230                errors.push(format!("Windows OS but platform is '{}'", self.platform));
231            }
232            "macOS" if self.platform != "MacIntel" => {
233                errors.push(format!("macOS but platform is '{}'", self.platform));
234            }
235            "Linux" if !self.platform.starts_with("Linux") => {
236                errors.push(format!("Linux OS but platform is '{}'", self.platform));
237            }
238            _ => {}
239        }
240
241        // Touch points: desktop = 0, mobile > 0
242        if self.max_touch_points > 0 && self.screen_width > 1024 && self.pointer_type == "fine" {
243            errors.push("Touch points > 0 but desktop pointer type".into());
244        }
245
246        // GPU vendor must match renderer
247        if self.webgl_renderer.contains("NVIDIA") && !self.webgl_vendor.contains("NVIDIA") {
248            errors.push("WebGL renderer is NVIDIA but vendor doesn't match".into());
249        }
250        if self.webgl_renderer.contains("Intel") && !self.webgl_vendor.contains("Intel") {
251            errors.push("WebGL renderer is Intel but vendor doesn't match".into());
252        }
253        if self.webgl_renderer.contains("Apple") && !self.webgl_vendor.contains("Apple") {
254            errors.push("WebGL renderer is Apple but vendor doesn't match".into());
255        }
256
257        // Apple GPU only on macOS/iOS
258        if self.webgl_renderer.contains("Apple")
259            && !matches!(self.os_name.as_str(), "macOS" | "iOS")
260        {
261            errors.push("Apple GPU on non-Apple OS".into());
262        }
263
264        // Screen dimensions sanity
265        if self.screen_width == 0 || self.screen_height == 0 {
266            errors.push("Screen dimensions cannot be zero".into());
267        }
268        if self.inner_width > self.screen_width {
269            errors.push("inner_width > screen_width".into());
270        }
271        if self.outer_width < self.inner_width {
272            errors.push("outer_width < inner_width".into());
273        }
274
275        // CPU/memory sanity
276        if self.cpu_cores == 0 || self.cpu_cores > 128 {
277            errors.push(format!("Unrealistic cpu_cores: {}", self.cpu_cores));
278        }
279        if self.device_memory == 0 && self.os_name != "iOS" {
280            errors.push(format!("Unrealistic device_memory: {}", self.device_memory));
281        }
282
283        // Language must be in languages list
284        if !self.languages.contains(&self.language) {
285            errors.push(format!(
286                "language '{}' not in languages {:?}",
287                self.language, self.languages
288            ));
289        }
290
291        // Client Hints consistency
292        if !matches!(self.cpu_architecture.as_str(), "x86" | "arm" | "") {
293            errors.push(format!(
294                "cpu_architecture must be 'x86', 'arm', or '' (got '{}')",
295                self.cpu_architecture
296            ));
297        }
298        if !matches!(self.cpu_bitness.as_str(), "64" | "32") {
299            errors.push(format!(
300                "cpu_bitness must be '64' or '32' (got '{}')",
301                self.cpu_bitness
302            ));
303        }
304        if self.ua_wow64 && (self.os_name != "Windows" || self.cpu_bitness != "32") {
305            errors.push(format!(
306                "ua_wow64=true requires os_name=Windows and cpu_bitness=32 (got {} / {})",
307                self.os_name, self.cpu_bitness
308            ));
309        }
310        if self.os_name == "Linux" && !self.platform_version.is_empty() {
311            errors.push(format!(
312                "Chrome on Linux must report empty platform_version (got '{}')",
313                self.platform_version
314            ));
315        }
316        if self.cpu_architecture == "arm"
317            && !matches!(
318                self.os_name.as_str(),
319                "macOS" | "Android" | "ChromeOS" | "iOS"
320            )
321        {
322            errors.push(format!(
323                "cpu_architecture=arm only on macOS/Android/ChromeOS/iOS (got '{}')",
324                self.os_name
325            ));
326        }
327        if !self.ua_model.is_empty() && self.max_touch_points == 0 {
328            errors.push(format!(
329                "ua_model='{}' on a desktop (max_touch_points=0) profile",
330                self.ua_model
331            ));
332        }
333        if !matches!(self.audio_sample_rate, 44100 | 48000 | 96000 | 192000) {
334            errors.push(format!(
335                "audio_sample_rate must be one of {{44100, 48000, 96000, 192000}} (got {})",
336                self.audio_sample_rate
337            ));
338        }
339
340        if errors.is_empty() {
341            Ok(())
342        } else {
343            Err(errors)
344        }
345    }
346}
347
348// ── Behavioral emulation (Sigma-Lognormal + Keystroke + Scroll) ─────
349
350/// Right-handers overshoot bottom-right; left-handers bottom-left.
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
352pub enum Handedness {
353    Right,
354    Left,
355}
356
357/// Trackpad momentum vs discrete mouse-wheel notches.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359pub enum ScrollStyle {
360    Trackpad,
361    Wheel,
362}
363
364/// Per-session behavioral parameters. Different sessions should sample
365/// fresh seeds so mouse/keyboard patterns don't repeat across visits.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct BehaviorProfile {
368    #[serde(default = "default_behavior_seed")]
369    pub seed: u64,
370    #[serde(default = "default_handedness")]
371    pub handedness: Handedness,
372    #[serde(default = "default_mouse_dpi")]
373    pub mouse_dpi: u16,
374    #[serde(default = "default_typing_wpm_mean")]
375    pub typing_wpm_mean: f32,
376    #[serde(default = "default_typing_wpm_sigma")]
377    pub typing_wpm_sigma: f32,
378    #[serde(default = "default_scroll_style")]
379    pub scroll_style: ScrollStyle,
380    #[serde(default = "default_fitts_b")]
381    pub fitts_b: f32,
382}
383
384fn default_behavior_seed() -> u64 {
385    rand::random::<u64>()
386}
387fn default_handedness() -> Handedness {
388    Handedness::Right
389}
390fn default_mouse_dpi() -> u16 {
391    1600
392}
393fn default_typing_wpm_mean() -> f32 {
394    50.0
395}
396fn default_typing_wpm_sigma() -> f32 {
397    15.0
398}
399fn default_scroll_style() -> ScrollStyle {
400    ScrollStyle::Trackpad
401}
402fn default_fitts_b() -> f32 {
403    166.0
404}
405
406impl Default for BehaviorProfile {
407    fn default() -> Self {
408        Self {
409            seed: default_behavior_seed(),
410            handedness: default_handedness(),
411            mouse_dpi: default_mouse_dpi(),
412            typing_wpm_mean: default_typing_wpm_mean(),
413            typing_wpm_sigma: default_typing_wpm_sigma(),
414            scroll_style: default_scroll_style(),
415            fitts_b: default_fitts_b(),
416        }
417    }
418}
419
420impl BehaviorProfile {
421    /// Derive a deterministic sub-RNG for a specific call site.
422    pub fn rng_for(&self, salt: u64) -> rand_chacha::ChaCha20Rng {
423        use rand_chacha::rand_core::SeedableRng;
424        let combined = self
425            .seed
426            .wrapping_mul(0x9E3779B97F4A7C15)
427            .wrapping_add(salt);
428        rand_chacha::ChaCha20Rng::seed_from_u64(combined)
429    }
430}
431
432/// One sample point on a humanized mouse trajectory.
433#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
434pub struct MousePoint {
435    pub t_ms: f32,
436    pub x: f32,
437    pub y: f32,
438}
439
440/// Keystroke timing for one character.
441#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
442pub struct KeystrokeTiming {
443    pub ch: char,
444    pub dwell_ms: f32,
445    pub flight_ms: f32,
446}
447
448/// A single scroll wheel tick.
449#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
450pub struct WheelTick {
451    pub t_ms: f32,
452    pub delta_y: f32,
453    pub mode: u32,
454}
455
456// ── Mouse trajectory (Sigma-Lognormal — Plamondon 1995) ─────────────
457
458struct Stroke {
459    amplitude: f32,
460    sigma: f32,
461    mu: f32,
462    t0: f32,
463    theta: f32,
464}
465
466fn integrate_x(strokes: &[Stroke], t: f32) -> f32 {
467    strokes
468        .iter()
469        .map(|s| {
470            let dt = t - s.t0;
471            if dt <= 0.0 {
472                return 0.0;
473            }
474            let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
475            let cdf = 0.5 * (1.0 + erf(z));
476            s.amplitude * cdf * s.theta.cos()
477        })
478        .sum()
479}
480
481fn integrate_y(strokes: &[Stroke], t: f32) -> f32 {
482    strokes
483        .iter()
484        .map(|s| {
485            let dt = t - s.t0;
486            if dt <= 0.0 {
487                return 0.0;
488            }
489            let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
490            let cdf = 0.5 * (1.0 + erf(z));
491            s.amplitude * cdf * s.theta.sin()
492        })
493        .sum()
494}
495
496/// Abramowitz-Stegun 7.1.26 erf approximation (|err| < 1.5e-7).
497fn erf(x: f32) -> f32 {
498    let sign = x.signum();
499    let x = x.abs();
500    let a1 = 0.254_829_6;
501    let a2 = -0.284_496_72;
502    let a3 = 1.421_413_8;
503    let a4 = -1.453_152_1;
504    let a5 = 1.061_405_4;
505    let p = 0.3275911;
506    let t = 1.0 / (1.0 + p * x);
507    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
508    sign * y
509}
510
511/// Generate a humanlike mouse trajectory from `from` to `to`.
512pub fn mouse_trajectory(
513    from: (f32, f32),
514    to: (f32, f32),
515    target_w: f32,
516    profile: &BehaviorProfile,
517) -> Vec<MousePoint> {
518    let mut rng = profile
519        .rng_for(((from.0 as u64) << 32) | (from.1 as u64) ^ ((to.0 as u64) << 16) ^ (to.1 as u64));
520    mouse_trajectory_with_rng(from, to, target_w, profile, &mut rng)
521}
522
523/// Same as `mouse_trajectory` but takes an explicit RNG for testing.
524pub fn mouse_trajectory_with_rng<R: rand::Rng>(
525    from: (f32, f32),
526    to: (f32, f32),
527    target_w: f32,
528    profile: &BehaviorProfile,
529    rng: &mut R,
530) -> Vec<MousePoint> {
531    use rand_distr::{Distribution, LogNormal, Normal};
532
533    let dx = to.0 - from.0;
534    let dy = to.1 - from.1;
535    let distance = (dx * dx + dy * dy).sqrt().max(1.0);
536    let target_w = target_w.max(1.0);
537
538    let id_bits = ((distance / target_w) + 1.0).log2();
539    let n_strokes = ((1.3 * id_bits).round() as usize).clamp(2, 7);
540
541    let total_ms = 230.0 + profile.fitts_b * id_bits;
542
543    let mut amplitudes: Vec<f32> = Vec::with_capacity(n_strokes);
544    let primary = 0.85 * distance;
545    amplitudes.push(primary);
546    let remaining = distance - primary;
547    let per_corrective = remaining / (n_strokes - 1).max(1) as f32;
548    for _ in 1..n_strokes {
549        let jitter: f32 = Normal::new(0.0_f32, per_corrective * 0.15)
550            .ok()
551            .map_or(0.0, |d| d.sample(rng));
552        amplitudes.push((per_corrective + jitter).max(1.0));
553    }
554
555    let sigma_dist = Normal::new(0.25_f32, 0.05).ok();
556    let mu_dist = Normal::new(-1.6_f32, 0.2).ok();
557    let onset_dist = LogNormal::new(90.0_f32.ln(), 0.3).ok();
558    let theta_dist = Normal::new(0.0_f32, 8.0_f32.to_radians()).ok();
559
560    let target_angle = dy.atan2(dx);
561    let mut strokes: Vec<Stroke> = Vec::with_capacity(n_strokes);
562    let mut t0 = 0.0_f32;
563    for (i, amp) in amplitudes.iter().enumerate() {
564        let sigma = sigma_dist
565            .as_ref()
566            .map_or(0.25, |d| d.sample(rng).clamp(0.15, 0.40));
567        let mu = mu_dist.as_ref().map_or(-1.6, |d| d.sample(rng));
568        let jitter = theta_dist.as_ref().map_or(0.0, |d| d.sample(rng));
569        let theta = if i == 0 {
570            target_angle + jitter
571        } else {
572            target_angle + jitter * 1.5
573        };
574        strokes.push(Stroke {
575            amplitude: *amp,
576            sigma,
577            mu,
578            t0,
579            theta,
580        });
581        t0 += onset_dist.as_ref().map_or(90.0, |d| d.sample(rng));
582    }
583
584    let dt_ms = 8.0_f32;
585    let n_samples = (total_ms / dt_ms).ceil() as usize + 1;
586    let mut points: Vec<MousePoint> = Vec::with_capacity(n_samples);
587
588    let tremor_dist = Normal::new(0.0_f32, 1.5).ok();
589    let mut tremor_x = 0.0_f32;
590    let mut tremor_y = 0.0_f32;
591    let tremor_alpha = 0.3_f32;
592
593    for i in 0..n_samples {
594        let t = (i as f32) * dt_ms;
595
596        let tx = tremor_dist.as_ref().map_or(0.0, |d| d.sample(rng));
597        let ty = tremor_dist.as_ref().map_or(0.0, |d| d.sample(rng));
598        tremor_x = tremor_alpha * tremor_x + (1.0 - tremor_alpha) * tx;
599        tremor_y = tremor_alpha * tremor_y + (1.0 - tremor_alpha) * ty;
600
601        let x = from.0 + integrate_x(&strokes, t) + tremor_x;
602        let y = from.1 + integrate_y(&strokes, t) + tremor_y;
603        points.push(MousePoint { t_ms: t, x, y });
604    }
605
606    // Smooth endpoint correction via smoothstep tail.
607    if points.len() >= 2 {
608        let n = points.len();
609        let last = &points[n - 1];
610        let res_x = to.0 - last.x;
611        let res_y = to.1 - last.y;
612        let tail = 15.min(n - 1);
613        let start = n - tail - 1;
614        for (k, p) in points.iter_mut().enumerate().skip(start) {
615            let u = (k - start) as f32 / tail as f32;
616            let s = u * u * (3.0 - 2.0 * u);
617            p.x += res_x * s;
618            p.y += res_y * s;
619        }
620        if let Some(last) = points.last_mut() {
621            last.x = to.0;
622            last.y = to.1;
623        }
624    } else if let Some(last) = points.last_mut() {
625        last.x = to.0;
626        last.y = to.1;
627    }
628    points
629}
630
631// ── Keystroke dynamics ──────────────────────────────────────────────
632
633fn bigram_ratio(prev: char, cur: char) -> f32 {
634    let key = (
635        prev.to_ascii_lowercase() as u8,
636        cur.to_ascii_lowercase() as u8,
637    );
638    match key {
639        (b't', b'h')
640        | (b'h', b'e')
641        | (b'i', b'n')
642        | (b'a', b'n')
643        | (b'o', b'n')
644        | (b'a', b't')
645        | (b'i', b's')
646        | (b'i', b't')
647        | (b'o', b'r')
648        | (b'o', b'f') => 0.7,
649        (b'e', b'd')
650        | (b'u', b'n')
651        | (b'r', b'e')
652        | (b'e', b'r')
653        | (b'e', b'n')
654        | (b'n', b'd')
655        | (b'e', b's')
656        | (b't', b'e')
657        | (b'a', b'l')
658        | (b'a', b'r') => 1.4,
659        (a, b) if a == b => 2.0,
660        _ => 1.0,
661    }
662}
663
664/// Generate keystroke timings for a string.
665pub fn keystroke_timings(text: &str, profile: &BehaviorProfile) -> Vec<KeystrokeTiming> {
666    let mut rng = profile.rng_for(0xCAFEBABE ^ text.len() as u64);
667    keystroke_timings_with_rng(text, profile, &mut rng)
668}
669
670/// Same as `keystroke_timings` but takes an explicit RNG for testing.
671pub fn keystroke_timings_with_rng<R: rand::Rng>(
672    text: &str,
673    profile: &BehaviorProfile,
674    rng: &mut R,
675) -> Vec<KeystrokeTiming> {
676    use rand_distr::{Distribution, LogNormal};
677
678    let ms_per_char = 60_000.0 / (profile.typing_wpm_mean * 5.0);
679    let flight_median = (ms_per_char - 95.0).max(40.0);
680    let flight_dist = LogNormal::new(flight_median.ln(), 0.55).ok();
681    let dwell_dist = LogNormal::new(95.0_f32.ln(), 0.30).ok();
682
683    let mut out = Vec::with_capacity(text.len());
684    let mut prev_ch: Option<char> = None;
685    for ch in text.chars() {
686        let dwell = dwell_dist
687            .as_ref()
688            .map_or(95.0, |d| d.sample(rng).clamp(40.0, 400.0));
689        let flight = if let Some(p) = prev_ch {
690            let ratio = bigram_ratio(p, ch);
691            flight_dist
692                .as_ref()
693                .map_or(130.0, |d| (d.sample(rng) * ratio).clamp(20.0, 1000.0))
694        } else {
695            0.0
696        };
697        out.push(KeystrokeTiming {
698            ch,
699            dwell_ms: dwell,
700            flight_ms: flight,
701        });
702        prev_ch = Some(ch);
703    }
704    out
705}
706
707// ── Scroll bursts ───────────────────────────────────────────────────
708
709/// Generate a humanlike scroll burst totaling ~`target_dy` pixels.
710pub fn wheel_burst(target_dy: f32, profile: &BehaviorProfile) -> Vec<WheelTick> {
711    let mut rng = profile.rng_for(0xDEAD_BEEF ^ target_dy.to_bits() as u64);
712    wheel_burst_with_rng(target_dy, profile, &mut rng)
713}
714
715/// Same as `wheel_burst` but takes an explicit RNG for testing.
716pub fn wheel_burst_with_rng<R: rand::RngExt>(
717    target_dy: f32,
718    profile: &BehaviorProfile,
719    rng: &mut R,
720) -> Vec<WheelTick> {
721    use rand_distr::{Distribution, LogNormal};
722
723    let dir = if target_dy >= 0.0 { 1.0 } else { -1.0 };
724    let abs_dy = target_dy.abs().max(1.0);
725
726    match profile.scroll_style {
727        ScrollStyle::Trackpad => {
728            let v0 = LogNormal::new((abs_dy / 8.0).ln(), 0.3)
729                .ok()
730                .map_or(abs_dy / 8.0, |d| d.sample(rng));
731            let decay = 0.94 + rng.random_range(0.0_f32..0.04);
732            let mut t = 0.0_f32;
733            let mut v = v0;
734            let mut ticks = Vec::new();
735            let mut accumulated = 0.0_f32;
736            while v > 0.5 && accumulated < abs_dy * 1.1 {
737                let step = (v.min(abs_dy - accumulated)).max(0.5);
738                ticks.push(WheelTick {
739                    t_ms: t,
740                    delta_y: step * dir,
741                    mode: 0,
742                });
743                accumulated += step;
744                t += 16.0;
745                v *= decay;
746            }
747            ticks
748        }
749        ScrollStyle::Wheel => {
750            let notches = ((abs_dy / 100.0).round() as u32).max(1);
751            let interval_dist = LogNormal::new(180.0_f32.ln(), 0.4).ok();
752            let mut t = 0.0_f32;
753            let mut ticks = Vec::with_capacity(notches as usize);
754            for _ in 0..notches {
755                ticks.push(WheelTick {
756                    t_ms: t,
757                    delta_y: 100.0 * dir,
758                    mode: 0,
759                });
760                t += interval_dist.as_ref().map_or(180.0, |d| d.sample(rng));
761            }
762            ticks
763        }
764    }
765}
766
767// ── GPU presets ──────────────────────────────────────────────────────
768
769fn common_params_desktop() -> Vec<(u32, serde_json::Value)> {
770    use serde_json::json;
771    vec![
772        (0x0D33, json!(16384)),
773        (0x851C, json!(16384)),
774        (0x84E8, json!(16384)),
775        (0x8073, json!(2048)),
776        (0x8869, json!(16)),
777        (0x8DFB, json!(1024)),
778        (0x8DFD, json!(15)),
779        (0x8DFC, json!(1024)),
780        (0x8872, json!(16)),
781        (0x8B4D, json!(16)),
782        (0x8B4C, json!(32)),
783        (0x846D, json!([1.0, 8190.0])),
784        (0x846E, json!([1.0, 1.0])),
785        (0x0D3A, json!([32767, 32767])),
786        (0x0D56, json!(8)),
787        (0x0D57, json!(8)),
788        (0x80AA, json!(2)),
789        (0x80A9, json!(4)),
790    ]
791}
792
793fn standard_shader_precision() -> Vec<(u32, u32, [i32; 3])> {
794    let mut out = Vec::with_capacity(12);
795    for &shader_type in &[0x8B31u32, 0x8B30u32] {
796        out.push((shader_type, 0x8DF0, [127, 127, 23]));
797        out.push((shader_type, 0x8DF1, [127, 127, 23]));
798        out.push((shader_type, 0x8DF2, [127, 127, 23]));
799        out.push((shader_type, 0x8DF3, [15, 14, 0]));
800        out.push((shader_type, 0x8DF4, [31, 30, 0]));
801        out.push((shader_type, 0x8DF5, [31, 30, 0]));
802    }
803    out
804}
805
806/// Chrome on Windows with NVIDIA GeForce RTX 3060.
807pub fn nvidia_rtx_3060_windows() -> GpuProfile {
808    GpuProfile {
809        vendor: "WebKit".into(),
810        renderer: "WebKit WebGL".into(),
811        version: "WebGL 1.0 (OpenGL ES 2.0 Chromium)".into(),
812        shading_language_version: "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)".into(),
813        unmasked_vendor: "Google Inc. (NVIDIA)".into(),
814        unmasked_renderer:
815            "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)".into(),
816        extensions: vec![
817            "ANGLE_instanced_arrays".into(),
818            "EXT_blend_minmax".into(),
819            "EXT_clip_control".into(),
820            "EXT_color_buffer_half_float".into(),
821            "EXT_depth_clamp".into(),
822            "EXT_disjoint_timer_query".into(),
823            "EXT_float_blend".into(),
824            "EXT_frag_depth".into(),
825            "EXT_polygon_offset_clamp".into(),
826            "EXT_shader_texture_lod".into(),
827            "EXT_texture_compression_bptc".into(),
828            "EXT_texture_compression_rgtc".into(),
829            "EXT_texture_filter_anisotropic".into(),
830            "EXT_texture_mirror_clamp_to_edge".into(),
831            "EXT_sRGB".into(),
832            "KHR_parallel_shader_compile".into(),
833            "OES_element_index_uint".into(),
834            "OES_fbo_render_mipmap".into(),
835            "OES_standard_derivatives".into(),
836            "OES_texture_float".into(),
837            "OES_texture_float_linear".into(),
838            "OES_texture_half_float".into(),
839            "OES_texture_half_float_linear".into(),
840            "OES_vertex_array_object".into(),
841            "WEBGL_blend_func_extended".into(),
842            "WEBGL_color_buffer_float".into(),
843            "WEBGL_compressed_texture_s3tc".into(),
844            "WEBGL_compressed_texture_s3tc_srgb".into(),
845            "WEBGL_debug_renderer_info".into(),
846            "WEBGL_debug_shaders".into(),
847            "WEBGL_depth_texture".into(),
848            "WEBGL_draw_buffers".into(),
849            "WEBGL_lose_context".into(),
850            "WEBGL_multi_draw".into(),
851            "WEBGL_polygon_mode".into(),
852        ],
853        params: common_params_desktop(),
854        shader_precision: standard_shader_precision(),
855        webgl1: None,
856    }
857}
858
859fn apple_m3_family_profile(chip_name: &str) -> GpuProfile {
860    GpuProfile {
861        vendor: "WebKit".into(),
862        renderer: "WebKit WebGL".into(),
863        version: "WebGL 2.0 (OpenGL ES 3.0 Chromium)".into(),
864        shading_language_version: "WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)".into(),
865        unmasked_vendor: "Google Inc. (Apple)".into(),
866        unmasked_renderer: format!(
867            "ANGLE (Apple, ANGLE Metal Renderer: {chip_name}, Unspecified Version)"
868        ),
869        extensions: vec![
870            "EXT_clip_control".into(),
871            "EXT_color_buffer_float".into(),
872            "EXT_color_buffer_half_float".into(),
873            "EXT_conservative_depth".into(),
874            "EXT_depth_clamp".into(),
875            "EXT_disjoint_timer_query_webgl2".into(),
876            "EXT_float_blend".into(),
877            "EXT_polygon_offset_clamp".into(),
878            "EXT_render_snorm".into(),
879            "EXT_texture_compression_bptc".into(),
880            "EXT_texture_compression_rgtc".into(),
881            "EXT_texture_filter_anisotropic".into(),
882            "EXT_texture_mirror_clamp_to_edge".into(),
883            "EXT_texture_norm16".into(),
884            "KHR_parallel_shader_compile".into(),
885            "NV_shader_noperspective_interpolation".into(),
886            "OES_draw_buffers_indexed".into(),
887            "OES_sample_variables".into(),
888            "OES_shader_multisample_interpolation".into(),
889            "OES_texture_float_linear".into(),
890            "WEBGL_blend_func_extended".into(),
891            "WEBGL_clip_cull_distance".into(),
892            "WEBGL_compressed_texture_astc".into(),
893            "WEBGL_compressed_texture_etc".into(),
894            "WEBGL_compressed_texture_etc1".into(),
895            "WEBGL_compressed_texture_pvrtc".into(),
896            "WEBGL_compressed_texture_s3tc".into(),
897            "WEBGL_compressed_texture_s3tc_srgb".into(),
898            "WEBGL_debug_renderer_info".into(),
899            "WEBGL_debug_shaders".into(),
900            "WEBGL_lose_context".into(),
901            "WEBGL_multi_draw".into(),
902            "WEBGL_polygon_mode".into(),
903            "WEBGL_provoking_vertex".into(),
904            "WEBGL_render_shared_exponent".into(),
905            "WEBGL_stencil_texturing".into(),
906        ],
907        params: apple_m3_params(),
908        shader_precision: standard_shader_precision(),
909        webgl1: Some(apple_m3_webgl1_surface()),
910    }
911}
912
913fn apple_m3_webgl1_surface() -> WebGL1Surface {
914    WebGL1Surface {
915        version: "WebGL 1.0 (OpenGL ES 2.0 Chromium)".into(),
916        shading_language_version: "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)".into(),
917        extensions: vec![
918            "ANGLE_instanced_arrays".into(),
919            "EXT_blend_minmax".into(),
920            "EXT_clip_control".into(),
921            "EXT_color_buffer_half_float".into(),
922            "EXT_depth_clamp".into(),
923            "EXT_disjoint_timer_query".into(),
924            "EXT_float_blend".into(),
925            "EXT_frag_depth".into(),
926            "EXT_polygon_offset_clamp".into(),
927            "EXT_sRGB".into(),
928            "EXT_shader_texture_lod".into(),
929            "EXT_texture_compression_bptc".into(),
930            "EXT_texture_compression_rgtc".into(),
931            "EXT_texture_filter_anisotropic".into(),
932            "EXT_texture_mirror_clamp_to_edge".into(),
933            "KHR_parallel_shader_compile".into(),
934            "OES_element_index_uint".into(),
935            "OES_fbo_render_mipmap".into(),
936            "OES_standard_derivatives".into(),
937            "OES_texture_float".into(),
938            "OES_texture_float_linear".into(),
939            "OES_texture_half_float".into(),
940            "OES_texture_half_float_linear".into(),
941            "OES_vertex_array_object".into(),
942            "WEBGL_blend_func_extended".into(),
943            "WEBGL_color_buffer_float".into(),
944            "WEBGL_compressed_texture_astc".into(),
945            "WEBGL_compressed_texture_etc".into(),
946            "WEBGL_compressed_texture_etc1".into(),
947            "WEBGL_compressed_texture_pvrtc".into(),
948            "WEBGL_compressed_texture_s3tc".into(),
949            "WEBGL_compressed_texture_s3tc_srgb".into(),
950            "WEBGL_debug_renderer_info".into(),
951            "WEBGL_debug_shaders".into(),
952            "WEBGL_depth_texture".into(),
953            "WEBGL_draw_buffers".into(),
954            "WEBGL_lose_context".into(),
955            "WEBGL_multi_draw".into(),
956            "WEBGL_polygon_mode".into(),
957        ],
958    }
959}
960
961fn apple_m3_params() -> Vec<(u32, serde_json::Value)> {
962    use serde_json::json;
963    let mut params = common_params_desktop();
964    for (pname, value) in params.iter_mut() {
965        match *pname {
966            0x0D3A => *value = json!([16384, 16384]),
967            0x846D => *value = json!([1.0, 511.0]),
968            _ => {}
969        }
970    }
971    params
972}
973
974/// Apple M3 GPU profile.
975pub fn apple_m3_macos() -> GpuProfile {
976    apple_m3_family_profile("Apple M3")
977}
978
979/// Apple M3 Pro GPU profile.
980pub fn apple_m3_pro_macos() -> GpuProfile {
981    apple_m3_family_profile("Apple M3 Pro")
982}
983
984/// Apple M3 Max GPU profile.
985pub fn apple_m3_max_macos() -> GpuProfile {
986    apple_m3_family_profile("Apple M3 Max")
987}
988
989/// Apple M2 Pro GPU profile.
990pub fn apple_m2_pro_macos() -> GpuProfile {
991    GpuProfile {
992        vendor: "WebKit".into(),
993        renderer: "WebKit WebGL".into(),
994        version: "WebGL 1.0 (OpenGL ES 2.0 Chromium)".into(),
995        shading_language_version: "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)".into(),
996        unmasked_vendor: "Google Inc. (Apple)".into(),
997        unmasked_renderer: "ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Pro, Unspecified Version)"
998            .into(),
999        extensions: vec![
1000            "ANGLE_instanced_arrays".into(),
1001            "EXT_blend_minmax".into(),
1002            "EXT_clip_control".into(),
1003            "EXT_color_buffer_half_float".into(),
1004            "EXT_depth_clamp".into(),
1005            "EXT_float_blend".into(),
1006            "EXT_frag_depth".into(),
1007            "EXT_polygon_offset_clamp".into(),
1008            "EXT_shader_texture_lod".into(),
1009            "EXT_texture_compression_bptc".into(),
1010            "EXT_texture_compression_rgtc".into(),
1011            "EXT_texture_filter_anisotropic".into(),
1012            "EXT_texture_mirror_clamp_to_edge".into(),
1013            "EXT_sRGB".into(),
1014            "KHR_parallel_shader_compile".into(),
1015            "OES_element_index_uint".into(),
1016            "OES_fbo_render_mipmap".into(),
1017            "OES_standard_derivatives".into(),
1018            "OES_texture_float".into(),
1019            "OES_texture_float_linear".into(),
1020            "OES_texture_half_float".into(),
1021            "OES_texture_half_float_linear".into(),
1022            "OES_vertex_array_object".into(),
1023            "WEBGL_blend_func_extended".into(),
1024            "WEBGL_color_buffer_float".into(),
1025            "WEBGL_compressed_texture_astc".into(),
1026            "WEBGL_compressed_texture_etc".into(),
1027            "WEBGL_compressed_texture_etc1".into(),
1028            "WEBGL_compressed_texture_s3tc".into(),
1029            "WEBGL_compressed_texture_s3tc_srgb".into(),
1030            "WEBGL_debug_renderer_info".into(),
1031            "WEBGL_debug_shaders".into(),
1032            "WEBGL_depth_texture".into(),
1033            "WEBGL_draw_buffers".into(),
1034            "WEBGL_lose_context".into(),
1035            "WEBGL_multi_draw".into(),
1036        ],
1037        params: common_params_desktop(),
1038        shader_precision: standard_shader_precision(),
1039        webgl1: None,
1040    }
1041}
1042
1043/// Intel UHD 630 on Linux.
1044pub fn intel_uhd_630_linux() -> GpuProfile {
1045    GpuProfile {
1046        vendor: "WebKit".into(),
1047        renderer: "WebKit WebGL".into(),
1048        version: "WebGL 1.0 (OpenGL ES 2.0 Chromium)".into(),
1049        shading_language_version: "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)".into(),
1050        unmasked_vendor: "Google Inc. (Intel)".into(),
1051        unmasked_renderer: "ANGLE (Intel, Mesa Intel(R) UHD Graphics 630 (CFL GT2), OpenGL 4.6)"
1052            .into(),
1053        extensions: vec![
1054            "ANGLE_instanced_arrays".into(),
1055            "EXT_blend_minmax".into(),
1056            "EXT_clip_control".into(),
1057            "EXT_color_buffer_half_float".into(),
1058            "EXT_depth_clamp".into(),
1059            "EXT_disjoint_timer_query".into(),
1060            "EXT_float_blend".into(),
1061            "EXT_frag_depth".into(),
1062            "EXT_polygon_offset_clamp".into(),
1063            "EXT_shader_texture_lod".into(),
1064            "EXT_texture_compression_bptc".into(),
1065            "EXT_texture_compression_rgtc".into(),
1066            "EXT_texture_filter_anisotropic".into(),
1067            "EXT_texture_mirror_clamp_to_edge".into(),
1068            "EXT_sRGB".into(),
1069            "KHR_parallel_shader_compile".into(),
1070            "OES_element_index_uint".into(),
1071            "OES_fbo_render_mipmap".into(),
1072            "OES_standard_derivatives".into(),
1073            "OES_texture_float".into(),
1074            "OES_texture_float_linear".into(),
1075            "OES_texture_half_float".into(),
1076            "OES_texture_half_float_linear".into(),
1077            "OES_vertex_array_object".into(),
1078            "WEBGL_compressed_texture_s3tc".into(),
1079            "WEBGL_compressed_texture_s3tc_srgb".into(),
1080            "WEBGL_debug_renderer_info".into(),
1081            "WEBGL_debug_shaders".into(),
1082            "WEBGL_depth_texture".into(),
1083            "WEBGL_draw_buffers".into(),
1084            "WEBGL_lose_context".into(),
1085            "WEBGL_multi_draw".into(),
1086        ],
1087        params: common_params_desktop(),
1088        shader_precision: standard_shader_precision(),
1089        webgl1: None,
1090    }
1091}
1092
1093// ── Media device helper ──────────────────────────────────────────────
1094
1095fn default_media_devices(seed: &str) -> Vec<MediaDeviceInfo> {
1096    use std::{
1097        collections::hash_map::DefaultHasher,
1098        hash::{Hash, Hasher},
1099    };
1100    let hash = |s: &str| -> String {
1101        let mut h = DefaultHasher::new();
1102        s.hash(&mut h);
1103        format!(
1104            "{:016x}{:016x}",
1105            h.finish(),
1106            h.finish().wrapping_mul(0x9e3779b97f4a7c15)
1107        )
1108    };
1109    vec![
1110        MediaDeviceInfo {
1111            device_id: hash(&format!("{seed}-audio-in")),
1112            kind: "audioinput".into(),
1113            label: "Default".into(),
1114            group_id: hash(&format!("{seed}-group-a")),
1115        },
1116        MediaDeviceInfo {
1117            device_id: hash(&format!("{seed}-audio-out")),
1118            kind: "audiooutput".into(),
1119            label: "Default".into(),
1120            group_id: hash(&format!("{seed}-group-a")),
1121        },
1122        MediaDeviceInfo {
1123            device_id: hash(&format!("{seed}-video-in")),
1124            kind: "videoinput".into(),
1125            label: "Integrated Camera".into(),
1126            group_id: hash(&format!("{seed}-group-v")),
1127        },
1128    ]
1129}
1130
1131// ── Profile presets ──────────────────────────────────────────────────
1132
1133/// Chrome 148 on Windows 10.
1134pub fn chrome_148_windows() -> StealthProfile {
1135    StealthProfile {
1136        user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1137        browser_name: "Chrome".into(),
1138        browser_version: "148.0.7778.168".into(),
1139        os_name: "Windows".into(),
1140        os_version: "10.0".into(),
1141        platform: "Win32".into(),
1142        vendor: "Google Inc.".into(),
1143        vendor_sub: "".into(),
1144        product_sub: "20030107".into(),
1145        app_version: "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1146        screen_width: 1920, screen_height: 1080,
1147        screen_avail_width: 1920, screen_avail_height: 1040,
1148        screen_avail_top: 0, screen_color_depth: 24,
1149        device_pixel_ratio: 1.0,
1150        cpu_cores: 8, device_memory: 8, max_touch_points: 0,
1151        webgl_vendor: "Google Inc. (NVIDIA)".into(),
1152        webgl_renderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)".into(),
1153        gpu_profile: nvidia_rtx_3060_windows(),
1154        language: "en-US".into(),
1155        languages: vec!["en-US".into(), "en".into()],
1156        timezone: "America/New_York".into(),
1157        cpu_architecture: "x86".into(), cpu_bitness: "64".into(),
1158        platform_version: "15.0.0".into(),
1159        ua_model: "".into(), ua_wow64: false,
1160        device_class: DeviceClass::Desktop,
1161        tls_impersonate: "chrome_147".into(),
1162        connection_effective_type: "4g".into(),
1163        connection_rtt: 50, connection_downlink: 10.0,
1164        pdf_viewer_enabled: true, plugins_count: 5, mime_types_count: 2,
1165        canvas_seed: 0x1234567890abcdef, audio_seed: 0xfedcba0987654321,
1166        audio_sample_rate: 44100,
1167        has_platform_authenticator: true, conditional_mediation: true,
1168        allow_http3: false,
1169        prefers_color_scheme: "light".into(),
1170        color_gamut: "srgb".into(),
1171        pointer_type: "fine".into(), hover_capability: "hover".into(),
1172        inner_width: 1920, inner_height: 969,
1173        outer_width: 1920, outer_height: 1080,
1174        proxy: None,
1175        media_devices: default_media_devices("win10"),
1176        enforce_csp: true,
1177    }
1178}
1179
1180/// Chrome 148 on macOS 15 (Apple Silicon M3).
1181pub fn chrome_148_macos() -> StealthProfile {
1182    StealthProfile {
1183        user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1184        browser_name: "Chrome".into(),
1185        browser_version: "148.0.7778.168".into(),
1186        os_name: "macOS".into(),
1187        os_version: "15.2".into(),
1188        platform: "MacIntel".into(),
1189        vendor: "Google Inc.".into(),
1190        vendor_sub: "".into(),
1191        product_sub: "20030107".into(),
1192        app_version: "5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1193        screen_width: 1512, screen_height: 982,
1194        screen_avail_width: 1512, screen_avail_height: 949,
1195        screen_avail_top: 33, screen_color_depth: 30,
1196        device_pixel_ratio: 2.0,
1197        cpu_cores: 8, device_memory: 8, max_touch_points: 0,
1198        webgl_vendor: "Google Inc. (Apple)".into(),
1199        webgl_renderer: "ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)".into(),
1200        gpu_profile: apple_m3_macos(),
1201        language: "en-US".into(),
1202        languages: vec!["en-US".into(), "en".into()],
1203        timezone: "America/Los_Angeles".into(),
1204        cpu_architecture: "arm".into(), cpu_bitness: "64".into(),
1205        platform_version: "15.2.0".into(),
1206        ua_model: "".into(), ua_wow64: false,
1207        device_class: DeviceClass::Desktop,
1208        tls_impersonate: "chrome_147".into(),
1209        connection_effective_type: "4g".into(),
1210        connection_rtt: 50, connection_downlink: 10.0,
1211        pdf_viewer_enabled: true, plugins_count: 5, mime_types_count: 2,
1212        canvas_seed: 0xabcdef1234567890, audio_seed: 0x0987654321fedcba,
1213        audio_sample_rate: 48000,
1214        has_platform_authenticator: true, conditional_mediation: true,
1215        allow_http3: false,
1216        prefers_color_scheme: "light".into(),
1217        color_gamut: "p3".into(),
1218        pointer_type: "fine".into(), hover_capability: "hover".into(),
1219        inner_width: 1512, inner_height: 871,
1220        outer_width: 1512, outer_height: 982,
1221        proxy: None,
1222        media_devices: default_media_devices("macos"),
1223        enforce_csp: true,
1224    }
1225}
1226
1227/// Chrome 148 on Linux.
1228pub fn chrome_148_linux() -> StealthProfile {
1229    StealthProfile {
1230        user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1231        browser_name: "Chrome".into(),
1232        browser_version: "148.0.7778.168".into(),
1233        os_name: "Linux".into(),
1234        os_version: "6.1".into(),
1235        platform: "Linux x86_64".into(),
1236        vendor: "Google Inc.".into(),
1237        vendor_sub: "".into(),
1238        product_sub: "20030107".into(),
1239        app_version: "5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36".into(),
1240        screen_width: 1920, screen_height: 1080,
1241        screen_avail_width: 1920, screen_avail_height: 1053,
1242        screen_avail_top: 0, screen_color_depth: 24,
1243        device_pixel_ratio: 1.0,
1244        cpu_cores: 8, device_memory: 8, max_touch_points: 0,
1245        webgl_vendor: "Google Inc. (Intel)".into(),
1246        webgl_renderer: "ANGLE (Intel, Mesa Intel(R) UHD Graphics 630 (CFL GT2), OpenGL 4.6)".into(),
1247        gpu_profile: intel_uhd_630_linux(),
1248        language: "en-US".into(),
1249        languages: vec!["en-US".into(), "en".into()],
1250        timezone: "America/Chicago".into(),
1251        cpu_architecture: "x86".into(), cpu_bitness: "64".into(),
1252        platform_version: "".into(),
1253        ua_model: "".into(), ua_wow64: false,
1254        device_class: DeviceClass::Desktop,
1255        tls_impersonate: "chrome_147".into(),
1256        connection_effective_type: "4g".into(),
1257        connection_rtt: 50, connection_downlink: 10.0,
1258        pdf_viewer_enabled: true, plugins_count: 5, mime_types_count: 2,
1259        canvas_seed: 0x1111222233334444, audio_seed: 0x5555666677778888,
1260        audio_sample_rate: 44100,
1261        has_platform_authenticator: false, conditional_mediation: true,
1262        allow_http3: false,
1263        prefers_color_scheme: "light".into(),
1264        color_gamut: "srgb".into(),
1265        pointer_type: "fine".into(), hover_capability: "hover".into(),
1266        inner_width: 1920, inner_height: 969,
1267        outer_width: 1920, outer_height: 1080,
1268        proxy: None,
1269        media_devices: default_media_devices("linux"),
1270        enforce_csp: true,
1271    }
1272}
1273
1274/// Chrome 148 on Windows — Russian locale (Moscow).
1275pub fn chrome_148_ru() -> StealthProfile {
1276    StealthProfile {
1277        language: "ru-RU".into(),
1278        languages: vec!["ru-RU".into(), "ru".into(), "en-US".into(), "en".into()],
1279        timezone: "Europe/Moscow".into(),
1280        connection_rtt: 100,
1281        connection_downlink: 8.0,
1282        canvas_seed: 0xaaaa_bbbb_cccc_dddd,
1283        audio_seed: 0xdddd_cccc_bbbb_aaaa,
1284        media_devices: default_media_devices("ru"),
1285        webgl_renderer:
1286            "ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 SUPER Direct3D11 vs_5_0 ps_5_0, D3D11)".into(),
1287        ..chrome_148_windows()
1288    }
1289}
1290
1291/// Chrome 148 on Windows — Chinese locale (Shanghai).
1292pub fn chrome_148_cn() -> StealthProfile {
1293    StealthProfile {
1294        language: "zh-CN".into(),
1295        languages: vec!["zh-CN".into(), "zh".into(), "en-US".into(), "en".into()],
1296        timezone: "Asia/Shanghai".into(),
1297        device_pixel_ratio: 1.25,
1298        cpu_cores: 12,
1299        device_memory: 16,
1300        connection_rtt: 150,
1301        connection_downlink: 6.0,
1302        canvas_seed: 0x1122_3344_5566_7788,
1303        audio_seed: 0x8877_6655_4433_2211,
1304        media_devices: default_media_devices("cn"),
1305        ..chrome_148_windows()
1306    }
1307}
1308
1309/// Chrome 148 on Windows — German locale (Berlin).
1310pub fn chrome_148_de() -> StealthProfile {
1311    StealthProfile {
1312        language: "de-DE".into(),
1313        languages: vec!["de-DE".into(), "de".into(), "en-US".into(), "en".into()],
1314        timezone: "Europe/Berlin".into(),
1315        canvas_seed: 0xdede_dede_dede_dede,
1316        audio_seed: 0xeded_eded_eded_eded,
1317        ..chrome_148_windows()
1318    }
1319}
1320
1321/// Chrome 148 on Windows — Japanese locale (Tokyo).
1322pub fn chrome_148_jp() -> StealthProfile {
1323    StealthProfile {
1324        language: "ja-JP".into(),
1325        languages: vec!["ja".into(), "en-US".into(), "en".into()],
1326        timezone: "Asia/Tokyo".into(),
1327        canvas_seed: 0x0a00_0000_0000_0001,
1328        audio_seed: 0x0a00_0000_0000_0002,
1329        ..chrome_148_windows()
1330    }
1331}
1332
1333// ── Firefox 135 presets ──────────────────────────────────────────────
1334
1335/// Firefox 135 on macOS.
1336pub fn firefox_135_macos() -> StealthProfile {
1337    StealthProfile {
1338        user_agent:
1339            "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:135.0) Gecko/20100101 Firefox/135.0"
1340                .into(),
1341        browser_name: "Firefox".into(),
1342        browser_version: "135.0".into(),
1343        os_name: "macOS".into(),
1344        os_version: "14.5".into(),
1345        platform: "MacIntel".into(),
1346        vendor: "".into(),
1347        vendor_sub: "".into(),
1348        product_sub: "20100101".into(),
1349        app_version: "5.0 (Macintosh; Intel Mac OS X 14.5; rv:135.0) Gecko/20100101 Firefox/135.0"
1350            .into(),
1351        screen_width: 1440,
1352        screen_height: 900,
1353        screen_avail_width: 1440,
1354        screen_avail_height: 875,
1355        screen_avail_top: 25,
1356        screen_color_depth: 30,
1357        device_pixel_ratio: 2.0,
1358        cpu_cores: 10,
1359        device_memory: 16,
1360        max_touch_points: 0,
1361        webgl_vendor: "Mozilla".into(),
1362        webgl_renderer: "Mozilla".into(),
1363        gpu_profile: apple_m2_pro_macos(),
1364        language: "en-US".into(),
1365        languages: vec!["en-US".into(), "en".into()],
1366        timezone: "America/Los_Angeles".into(),
1367        cpu_architecture: "arm".into(),
1368        cpu_bitness: "64".into(),
1369        platform_version: "14.5.0".into(),
1370        ua_model: "".into(),
1371        ua_wow64: false,
1372        device_class: DeviceClass::Desktop,
1373        tls_impersonate: "firefox_135".into(),
1374        connection_effective_type: "4g".into(),
1375        connection_rtt: 50,
1376        connection_downlink: 10.0,
1377        pdf_viewer_enabled: true,
1378        plugins_count: 5,
1379        mime_types_count: 2,
1380        canvas_seed: 0xff0011_ff0022_ff0033_u128 as u64,
1381        audio_seed: 0x88aa_bbcc_ddee_ff00,
1382        audio_sample_rate: 44100,
1383        has_platform_authenticator: true,
1384        conditional_mediation: true,
1385        allow_http3: false,
1386        prefers_color_scheme: "light".into(),
1387        color_gamut: "p3".into(),
1388        pointer_type: "fine".into(),
1389        hover_capability: "hover".into(),
1390        inner_width: 1440,
1391        inner_height: 789,
1392        outer_width: 1440,
1393        outer_height: 900,
1394        proxy: None,
1395        media_devices: default_media_devices("macos"),
1396        enforce_csp: true,
1397    }
1398}
1399
1400/// Firefox 135 on Windows 10.
1401pub fn firefox_135_windows() -> StealthProfile {
1402    StealthProfile {
1403        user_agent:
1404            "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0"
1405                .into(),
1406        browser_name: "Firefox".into(),
1407        browser_version: "135.0".into(),
1408        os_name: "Windows".into(),
1409        os_version: "10.0".into(),
1410        platform: "Win32".into(),
1411        vendor: "".into(),
1412        vendor_sub: "".into(),
1413        product_sub: "20100101".into(),
1414        app_version: "5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0"
1415            .into(),
1416        screen_width: 1920,
1417        screen_height: 1080,
1418        screen_avail_width: 1920,
1419        screen_avail_height: 1040,
1420        screen_avail_top: 0,
1421        screen_color_depth: 24,
1422        device_pixel_ratio: 1.0,
1423        cpu_cores: 8,
1424        device_memory: 8,
1425        max_touch_points: 0,
1426        webgl_vendor: "Mozilla".into(),
1427        webgl_renderer: "Mozilla".into(),
1428        gpu_profile: nvidia_rtx_3060_windows(),
1429        language: "en-US".into(),
1430        languages: vec!["en-US".into(), "en".into()],
1431        timezone: "America/New_York".into(),
1432        cpu_architecture: "x86".into(),
1433        cpu_bitness: "64".into(),
1434        platform_version: "15.0.0".into(),
1435        ua_model: "".into(),
1436        ua_wow64: false,
1437        device_class: DeviceClass::Desktop,
1438        tls_impersonate: "firefox_135".into(),
1439        connection_effective_type: "4g".into(),
1440        connection_rtt: 50,
1441        connection_downlink: 10.0,
1442        pdf_viewer_enabled: true,
1443        plugins_count: 5,
1444        mime_types_count: 2,
1445        canvas_seed: 0x1122_3344_5566_7788,
1446        audio_seed: 0x99aa_bbcc_ddee_ff00,
1447        audio_sample_rate: 44100,
1448        has_platform_authenticator: true,
1449        conditional_mediation: true,
1450        allow_http3: false,
1451        prefers_color_scheme: "light".into(),
1452        color_gamut: "srgb".into(),
1453        pointer_type: "fine".into(),
1454        hover_capability: "hover".into(),
1455        inner_width: 1920,
1456        inner_height: 969,
1457        outer_width: 1920,
1458        outer_height: 1080,
1459        proxy: None,
1460        media_devices: default_media_devices("windows"),
1461        enforce_csp: true,
1462    }
1463}
1464
1465/// Firefox 135 on Linux.
1466pub fn firefox_135_linux() -> StealthProfile {
1467    StealthProfile {
1468        user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0".into(),
1469        browser_name: "Firefox".into(),
1470        browser_version: "135.0".into(),
1471        os_name: "Linux".into(),
1472        os_version: "6.1".into(),
1473        platform: "Linux x86_64".into(),
1474        vendor: "".into(),
1475        vendor_sub: "".into(),
1476        product_sub: "20100101".into(),
1477        app_version: "5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0".into(),
1478        screen_width: 1920,
1479        screen_height: 1080,
1480        screen_avail_width: 1920,
1481        screen_avail_height: 1053,
1482        screen_avail_top: 0,
1483        screen_color_depth: 24,
1484        device_pixel_ratio: 1.0,
1485        cpu_cores: 8,
1486        device_memory: 8,
1487        max_touch_points: 0,
1488        webgl_vendor: "Mozilla".into(),
1489        webgl_renderer: "Mozilla".into(),
1490        gpu_profile: intel_uhd_630_linux(),
1491        language: "en-US".into(),
1492        languages: vec!["en-US".into(), "en".into()],
1493        timezone: "America/Chicago".into(),
1494        cpu_architecture: "x86".into(),
1495        cpu_bitness: "64".into(),
1496        platform_version: "".into(),
1497        ua_model: "".into(),
1498        ua_wow64: false,
1499        device_class: DeviceClass::Desktop,
1500        tls_impersonate: "firefox_135".into(),
1501        connection_effective_type: "4g".into(),
1502        connection_rtt: 50,
1503        connection_downlink: 10.0,
1504        pdf_viewer_enabled: true,
1505        plugins_count: 5,
1506        mime_types_count: 2,
1507        canvas_seed: 0xaaaa_bbbb_cccc_dddd,
1508        audio_seed: 0xdddd_cccc_bbbb_aaaa,
1509        audio_sample_rate: 44100,
1510        has_platform_authenticator: false,
1511        conditional_mediation: true,
1512        allow_http3: false,
1513        prefers_color_scheme: "light".into(),
1514        color_gamut: "srgb".into(),
1515        pointer_type: "fine".into(),
1516        hover_capability: "hover".into(),
1517        inner_width: 1920,
1518        inner_height: 969,
1519        outer_width: 1920,
1520        outer_height: 1080,
1521        proxy: None,
1522        media_devices: default_media_devices("linux"),
1523        enforce_csp: true,
1524    }
1525}
1526
1527// ── Mobile presets ───────────────────────────────────────────────────
1528
1529/// Chrome 148 on Pixel 9 Pro (Android 15).
1530pub fn pixel_9_pro_chrome_148() -> StealthProfile {
1531    StealthProfile {
1532        user_agent: "Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro Build/AP4A.250105.002) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36".into(),
1533        browser_name: "Chrome".into(),
1534        browser_version: "148.0.7778.168".into(),
1535        os_name: "Android".into(),
1536        os_version: "15".into(),
1537        platform: "Linux armv81".into(),
1538        vendor: "Google Inc.".into(),
1539        vendor_sub: "".into(),
1540        product_sub: "20030107".into(),
1541        app_version: "5.0 (Linux; Android 15; Pixel 9 Pro Build/AP4A.250105.002) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36".into(),
1542        screen_width: 412, screen_height: 870,
1543        screen_avail_width: 412, screen_avail_height: 870,
1544        screen_avail_top: 0, screen_color_depth: 24,
1545        device_pixel_ratio: 2.625,
1546        cpu_cores: 8, device_memory: 8, max_touch_points: 5,
1547        webgl_vendor: "Google Inc. (Google)".into(),
1548        webgl_renderer: "ANGLE (Google, Mali-G715 MP7, OpenGL ES 3.2)".into(),
1549        gpu_profile: apple_m3_macos(), // ponytail: placeholder, needs android GPU profile
1550        language: "en-US".into(),
1551        languages: vec!["en-US".into(), "en".into()],
1552        timezone: "America/Los_Angeles".into(),
1553        cpu_architecture: "".into(), cpu_bitness: "64".into(),
1554        platform_version: "15.0.0".into(),
1555        ua_model: "Pixel 9 Pro".into(), ua_wow64: false,
1556        device_class: DeviceClass::MobileAndroid,
1557        tls_impersonate: "chrome_147_android".into(),
1558        connection_effective_type: "4g".into(),
1559        connection_rtt: 50, connection_downlink: 10.0,
1560        pdf_viewer_enabled: false, plugins_count: 0, mime_types_count: 0,
1561        canvas_seed: 0xa5a5_d5d5_3c3c_e6e6, audio_seed: 0x9c9c_5e5e_4040_b1b1,
1562        audio_sample_rate: 44100,
1563        has_platform_authenticator: false, conditional_mediation: true,
1564        allow_http3: false,
1565        prefers_color_scheme: "light".into(),
1566        color_gamut: "srgb".into(),
1567        pointer_type: "coarse".into(), hover_capability: "none".into(),
1568        inner_width: 412, inner_height: 870,
1569        outer_width: 412, outer_height: 870,
1570        proxy: None,
1571        media_devices: default_media_devices("android"),
1572        enforce_csp: true,
1573    }
1574}
1575
1576/// Mobile Safari 18 on iPhone 15 Pro (iOS 18).
1577pub fn iphone_15_pro_safari_18() -> StealthProfile {
1578    StealthProfile {
1579        user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1".into(),
1580        browser_name: "Safari".into(),
1581        browser_version: "18.0.1".into(),
1582        os_name: "iOS".into(),
1583        os_version: "18.0.1".into(),
1584        platform: "iPhone".into(),
1585        vendor: "Apple Computer, Inc.".into(),
1586        vendor_sub: "".into(),
1587        product_sub: "20030107".into(),
1588        app_version: "5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0.1 Mobile/15E148 Safari/604.1".into(),
1589        screen_width: 393, screen_height: 852,
1590        screen_avail_width: 393, screen_avail_height: 852,
1591        screen_avail_top: 0, screen_color_depth: 24,
1592        device_pixel_ratio: 3.0,
1593        cpu_cores: 2, device_memory: 0, max_touch_points: 5,
1594        webgl_vendor: "Apple Inc.".into(),
1595        webgl_renderer: "Apple GPU".into(),
1596        gpu_profile: apple_m3_macos(), // ponytail: placeholder, needs iOS GPU profile
1597        language: "en-US".into(),
1598        languages: vec!["en-US".into(), "en".into()],
1599        timezone: "America/Los_Angeles".into(),
1600        cpu_architecture: "arm".into(), cpu_bitness: "64".into(),
1601        platform_version: "18.0.1".into(),
1602        ua_model: "iPhone".into(), ua_wow64: false,
1603        device_class: DeviceClass::MobileIOS,
1604        tls_impersonate: "safari_18_ios".into(),
1605        connection_effective_type: "4g".into(),
1606        connection_rtt: 50, connection_downlink: 10.0,
1607        pdf_viewer_enabled: false, plugins_count: 0, mime_types_count: 0,
1608        canvas_seed: 0xa1b2_c3d4_e5f6_0708, audio_seed: 0x0807_0605_0403_0201,
1609        audio_sample_rate: 44100,
1610        has_platform_authenticator: false, conditional_mediation: true,
1611        allow_http3: false,
1612        prefers_color_scheme: "light".into(),
1613        color_gamut: "p3".into(),
1614        pointer_type: "coarse".into(), hover_capability: "none".into(),
1615        inner_width: 393, inner_height: 852,
1616        outer_width: 393, outer_height: 852,
1617        proxy: None,
1618        media_devices: default_media_devices("ios"),
1619        enforce_csp: true,
1620    }
1621}
1622
1623// ── Utility functions ────────────────────────────────────────────────
1624
1625/// Create a profile with custom locale/timezone from a base profile.
1626pub fn with_locale(
1627    mut base: StealthProfile,
1628    language: &str,
1629    languages: &[&str],
1630    timezone: &str,
1631) -> StealthProfile {
1632    base.language = language.into();
1633    base.languages = languages.iter().map(|s| (*s).to_string()).collect();
1634    base.timezone = timezone.into();
1635    base
1636}
1637
1638/// Random desktop profile (picks randomly from Chrome presets).
1639pub fn random_desktop() -> StealthProfile {
1640    use rand::RngExt;
1641    let mut rng = rand::rng();
1642    let mut profile = match rng.random_range(0..3u32) {
1643        0 => chrome_148_windows(),
1644        1 => chrome_148_macos(),
1645        _ => chrome_148_linux(),
1646    };
1647    profile.canvas_seed = rng.random();
1648    profile.audio_seed = rng.random();
1649    profile
1650}
1651
1652/// Apple Silicon Chrome 148 profile sampler.
1653///
1654/// Returns one variant of `chrome_148_macos` with screen geometry, core
1655/// count, RAM, and fingerprint seeds independently sampled from
1656/// realistic Apple Silicon distributions.
1657pub fn chrome_148_macos_sampled() -> StealthProfile {
1658    chrome_148_macos_sampled_with_rng(&mut rand::rng())
1659}
1660
1661/// As [`chrome_148_macos_sampled`] but takes a caller-supplied RNG.
1662pub fn chrome_148_macos_sampled_with_rng(rng: &mut impl rand::RngExt) -> StealthProfile {
1663    let mut p = chrome_148_macos();
1664
1665    type ChipConfig = (
1666        &'static [u8],
1667        &'static [u8],
1668        &'static [(u32, u32, u32)],
1669        GpuProfile,
1670    );
1671    let chip_idx = rng.random_range(0..3u32);
1672    let (cores_pool, ram_pool, screens, gpu): ChipConfig = match chip_idx {
1673        0 => (
1674            &[8],
1675            &[8, 16, 24],
1676            &[(1512, 982, 949), (1728, 1117, 1010)],
1677            apple_m3_macos(),
1678        ),
1679        1 => (
1680            &[11, 12],
1681            &[18, 36],
1682            &[(1800, 1169, 1100), (2056, 1329, 1253)],
1683            apple_m3_pro_macos(),
1684        ),
1685        _ => (
1686            &[14, 16],
1687            &[36, 48],
1688            &[(1800, 1169, 1100), (2056, 1329, 1253)],
1689            apple_m3_max_macos(),
1690        ),
1691    };
1692
1693    p.cpu_cores = cores_pool[rng.random_range(0..cores_pool.len())];
1694    p.device_memory = ram_pool[rng.random_range(0..ram_pool.len())];
1695
1696    let (w, h, ah) = screens[rng.random_range(0..screens.len())];
1697    p.screen_width = w;
1698    p.screen_height = h;
1699    p.screen_avail_width = w;
1700    p.screen_avail_height = ah;
1701    p.inner_width = w;
1702    p.inner_height = h.saturating_sub(111);
1703    p.outer_width = w;
1704    p.outer_height = h;
1705
1706    p.gpu_profile = gpu;
1707    p.webgl_renderer = p.gpu_profile.unmasked_renderer.clone();
1708
1709    p.canvas_seed = rng.random();
1710    p.audio_seed = rng.random();
1711
1712    debug_assert!(
1713        p.validate().is_ok(),
1714        "chrome_148_macos_sampled produced an invalid profile: {:?}",
1715        p.validate()
1716    );
1717
1718    p
1719}
1720
1721// ── Compat presets module (for headers.rs tests) ─────────────────────
1722
1723/// Test presets re-exported as a module for backward compat.
1724#[cfg(test)]
1725pub mod presets {
1726    use super::*;
1727
1728    pub fn chrome_147_macos() -> StealthProfile {
1729        chrome_148_macos()
1730    }
1731    pub fn chrome_147_windows() -> StealthProfile {
1732        chrome_148_windows()
1733    }
1734    pub fn chrome_147_linux() -> StealthProfile {
1735        chrome_148_linux()
1736    }
1737    pub fn firefox_135_macos() -> StealthProfile {
1738        super::firefox_135_macos()
1739    }
1740    pub fn safari_ios_18() -> StealthProfile {
1741        iphone_15_pro_safari_18()
1742    }
1743    pub fn pixel_9_pro_chrome_148() -> StealthProfile {
1744        super::pixel_9_pro_chrome_148()
1745    }
1746}
1747
1748// ── Tests ────────────────────────────────────────────────────────────
1749
1750#[cfg(test)]
1751mod tests {
1752    use super::*;
1753
1754    #[test]
1755    fn chrome_148_windows_validates() {
1756        let p = chrome_148_windows();
1757        assert!(p.validate().is_ok(), "{:?}", p.validate());
1758    }
1759
1760    #[test]
1761    fn chrome_148_macos_validates() {
1762        let p = chrome_148_macos();
1763        assert!(p.validate().is_ok(), "{:?}", p.validate());
1764    }
1765
1766    #[test]
1767    fn chrome_148_linux_validates() {
1768        let p = chrome_148_linux();
1769        assert!(p.validate().is_ok(), "{:?}", p.validate());
1770    }
1771
1772    #[test]
1773    fn chrome_148_ru_validates() {
1774        let p = chrome_148_ru();
1775        assert!(p.validate().is_ok(), "{:?}", p.validate());
1776    }
1777
1778    #[test]
1779    fn chrome_148_cn_validates() {
1780        let p = chrome_148_cn();
1781        assert!(p.validate().is_ok(), "{:?}", p.validate());
1782    }
1783
1784    #[test]
1785    fn firefox_135_macos_validates() {
1786        let p = firefox_135_macos();
1787        assert!(p.validate().is_ok(), "{:?}", p.validate());
1788        assert_eq!(p.browser_name, "Firefox");
1789        assert_eq!(p.vendor, "");
1790        assert_eq!(p.product_sub, "20100101");
1791        assert!(p.user_agent.contains("rv:135.0"));
1792        assert!(p.user_agent.contains("Firefox/135.0"));
1793        assert!(!p.user_agent.contains("Chrome"));
1794    }
1795
1796    #[test]
1797    fn firefox_135_windows_validates() {
1798        let p = firefox_135_windows();
1799        assert!(p.validate().is_ok(), "{:?}", p.validate());
1800        assert!(p.user_agent.contains("Firefox/135.0"));
1801    }
1802
1803    #[test]
1804    fn firefox_135_linux_validates() {
1805        let p = firefox_135_linux();
1806        assert!(p.validate().is_ok(), "{:?}", p.validate());
1807        assert!(p.user_agent.contains("Firefox/135.0"));
1808    }
1809
1810    #[test]
1811    fn pixel_9_pro_validates() {
1812        let p = pixel_9_pro_chrome_148();
1813        assert!(p.validate().is_ok(), "{:?}", p.validate());
1814    }
1815
1816    #[test]
1817    fn iphone_15_pro_validates() {
1818        let p = iphone_15_pro_safari_18();
1819        assert!(p.validate().is_ok(), "{:?}", p.validate());
1820    }
1821
1822    #[test]
1823    fn http3_disabled_by_default_on_all_presets() {
1824        for profile in [
1825            chrome_148_windows(),
1826            chrome_148_macos(),
1827            chrome_148_linux(),
1828            chrome_148_ru(),
1829            chrome_148_cn(),
1830            chrome_148_de(),
1831            chrome_148_jp(),
1832            firefox_135_macos(),
1833            firefox_135_windows(),
1834            firefox_135_linux(),
1835        ] {
1836            assert!(
1837                !profile.allow_http3,
1838                "Profile sets allow_http3=true: {}",
1839                profile.user_agent
1840            );
1841        }
1842    }
1843
1844    #[test]
1845    fn firefox_webgl_is_masked() {
1846        for profile in [
1847            firefox_135_macos(),
1848            firefox_135_windows(),
1849            firefox_135_linux(),
1850        ] {
1851            assert_eq!(profile.webgl_vendor, "Mozilla");
1852            assert_eq!(profile.webgl_renderer, "Mozilla");
1853        }
1854    }
1855
1856    #[test]
1857    fn random_desktop_validates() {
1858        for _ in 0..10 {
1859            let p = random_desktop();
1860            assert!(p.validate().is_ok(), "{:?}", p.validate());
1861        }
1862    }
1863
1864    #[test]
1865    fn random_desktop_diversity() {
1866        use std::collections::HashSet;
1867        let mut names = HashSet::new();
1868        for _ in 0..30 {
1869            let p = random_desktop();
1870            names.insert(p.browser_name.clone());
1871        }
1872        // All Chrome presets share browser_name="Chrome", so diversity
1873        // comes from screen/seed variation. At least 1 name expected.
1874        assert!(!names.is_empty());
1875    }
1876
1877    #[test]
1878    fn invalid_profile_detected() {
1879        let mut p = chrome_148_windows();
1880        p.platform = "MacIntel".into();
1881        assert!(p.validate().is_err());
1882    }
1883
1884    #[test]
1885    fn invalid_gpu_os_mismatch() {
1886        let mut p = chrome_148_windows();
1887        p.webgl_renderer =
1888            "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)".into();
1889        p.webgl_vendor = "Google Inc. (Apple)".into();
1890        assert!(p.validate().is_err());
1891    }
1892
1893    #[test]
1894    fn ua_contains_version() {
1895        let p = chrome_148_windows();
1896        assert!(p.user_agent.contains("148.0.0.0"));
1897        assert_eq!(p.browser_version, "148.0.7778.168");
1898    }
1899
1900    #[test]
1901    fn serialization_roundtrip() {
1902        let p = chrome_148_windows();
1903        let json = serde_json::to_string(&p).unwrap();
1904        let deserialized: StealthProfile = serde_json::from_str(&json).unwrap();
1905        assert_eq!(p.user_agent, deserialized.user_agent);
1906        assert_eq!(p.screen_width, deserialized.screen_width);
1907    }
1908
1909    #[test]
1910    fn macos_sampler_produces_valid_profiles() {
1911        for _ in 0..200 {
1912            let p = chrome_148_macos_sampled();
1913            p.validate()
1914                .unwrap_or_else(|e| panic!("invalid sampled profile: {e:?}"));
1915            assert!(matches!(p.screen_width, 1512 | 1728 | 1800 | 2056));
1916            assert!(matches!(p.cpu_cores, 8 | 11 | 12 | 14 | 16));
1917            assert!(matches!(p.device_memory, 8 | 16 | 18 | 24 | 36 | 48));
1918            assert_eq!(p.device_pixel_ratio, 2.0);
1919            assert_eq!(p.audio_sample_rate, 48000);
1920            assert_eq!(p.cpu_architecture, "arm");
1921            assert_eq!(p.platform, "MacIntel");
1922            assert_eq!(p.inner_height + 111, p.screen_height);
1923        }
1924    }
1925
1926    #[test]
1927    fn macos_sampler_keeps_cross_api_consistency() {
1928        for _ in 0..50 {
1929            let p = chrome_148_macos_sampled();
1930            let r = &p.gpu_profile.unmasked_renderer;
1931            match p.cpu_cores {
1932                8 => {
1933                    assert!(r.contains("Apple M3,"));
1934                    assert!(matches!(p.device_memory, 8 | 16 | 24));
1935                }
1936                11 | 12 => {
1937                    assert!(r.contains("Apple M3 Pro"));
1938                    assert!(matches!(p.device_memory, 18 | 36));
1939                }
1940                14 | 16 => {
1941                    assert!(r.contains("Apple M3 Max"));
1942                    assert!(matches!(p.device_memory, 36 | 48));
1943                }
1944                other => panic!("unexpected cpu_cores {other}"),
1945            }
1946            assert_eq!(p.webgl_renderer, *r);
1947        }
1948    }
1949
1950    // ── Behavior tests ─────────────────────────────────────────────
1951
1952    use rand_chacha::rand_core::SeedableRng;
1953
1954    fn fixed_rng() -> rand_chacha::ChaCha20Rng {
1955        rand_chacha::ChaCha20Rng::seed_from_u64(42)
1956    }
1957
1958    #[test]
1959    fn behavior_profile_defaults_are_sensible() {
1960        let p = BehaviorProfile::default();
1961        assert!((30.0..=80.0).contains(&p.typing_wpm_mean));
1962        assert!((130.0..=220.0).contains(&p.fitts_b));
1963        assert_eq!(p.handedness, Handedness::Right);
1964    }
1965
1966    #[test]
1967    fn rng_for_is_deterministic_per_seed() {
1968        let p = BehaviorProfile {
1969            seed: 99,
1970            ..BehaviorProfile::default()
1971        };
1972        let mut a = p.rng_for(123);
1973        let mut b = p.rng_for(123);
1974        use rand::RngExt;
1975        assert_eq!(a.random::<u64>(), b.random::<u64>());
1976    }
1977
1978    #[test]
1979    fn rng_for_differs_across_salts() {
1980        let p = BehaviorProfile {
1981            seed: 99,
1982            ..BehaviorProfile::default()
1983        };
1984        let mut a = p.rng_for(1);
1985        let mut b = p.rng_for(2);
1986        use rand::RngExt;
1987        assert_ne!(a.random::<u64>(), b.random::<u64>());
1988    }
1989
1990    #[test]
1991    fn mouse_trajectory_starts_at_from_and_ends_at_to() {
1992        let p = BehaviorProfile {
1993            seed: 42,
1994            ..BehaviorProfile::default()
1995        };
1996        let pts = mouse_trajectory((100.0, 100.0), (500.0, 400.0), 50.0, &p);
1997        assert!(pts.len() > 5);
1998        let first = pts[0];
1999        let last = pts[pts.len() - 1];
2000        assert!((first.x - 100.0).abs() < 10.0, "first x={}", first.x);
2001        assert!((first.y - 100.0).abs() < 10.0, "first y={}", first.y);
2002        assert_eq!(last.x, 500.0);
2003        assert_eq!(last.y, 400.0);
2004    }
2005
2006    #[test]
2007    fn mouse_trajectory_obeys_fitts_law_total_time() {
2008        let p = BehaviorProfile {
2009            seed: 42,
2010            ..BehaviorProfile::default()
2011        };
2012        let pts = mouse_trajectory((0.0, 0.0), (500.0, 0.0), 50.0, &p);
2013        let last_t = pts[pts.len() - 1].t_ms;
2014        assert!(
2015            (700.0..=950.0).contains(&last_t),
2016            "expected ~805 ms, got {last_t}"
2017        );
2018    }
2019
2020    #[test]
2021    fn mouse_trajectory_uses_8ms_sample_rate() {
2022        let p = BehaviorProfile {
2023            seed: 42,
2024            ..BehaviorProfile::default()
2025        };
2026        let pts = mouse_trajectory((0.0, 0.0), (200.0, 0.0), 30.0, &p);
2027        for w in pts.windows(2) {
2028            let dt = w[1].t_ms - w[0].t_ms;
2029            assert!((dt - 8.0).abs() < 1e-3, "gap {} not 8 ms", dt);
2030        }
2031    }
2032
2033    #[test]
2034    fn mouse_trajectory_has_velocity_diversity() {
2035        let p = BehaviorProfile {
2036            seed: 42,
2037            ..BehaviorProfile::default()
2038        };
2039        let mut rng = fixed_rng();
2040        let pts = mouse_trajectory_with_rng((0.0, 0.0), (600.0, 400.0), 40.0, &p, &mut rng);
2041        let speeds: Vec<f32> = pts
2042            .windows(2)
2043            .map(|w| ((w[1].x - w[0].x).powi(2) + (w[1].y - w[0].y).powi(2)).sqrt())
2044            .collect();
2045        let mean = speeds.iter().sum::<f32>() / speeds.len() as f32;
2046        let var = speeds.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / speeds.len() as f32;
2047        let std = var.sqrt();
2048        let cv = std / mean.max(1e-3);
2049        assert!(cv > 0.4, "speed CV too low: {cv}");
2050    }
2051
2052    #[test]
2053    fn mouse_trajectory_deterministic_per_seed() {
2054        let p = BehaviorProfile {
2055            seed: 123,
2056            ..BehaviorProfile::default()
2057        };
2058        let mut r1 = p.rng_for(1);
2059        let mut r2 = p.rng_for(1);
2060        let a = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r1);
2061        let b = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r2);
2062        assert_eq!(a.len(), b.len());
2063        for (pa, pb) in a.iter().zip(b.iter()) {
2064            assert_eq!(pa, pb);
2065        }
2066    }
2067
2068    #[test]
2069    fn mouse_trajectory_no_endpoint_jerk_spike() {
2070        for seed in 0..40u64 {
2071            let p = BehaviorProfile {
2072                seed,
2073                ..BehaviorProfile::default()
2074            };
2075            let mut r = p.rng_for(2);
2076            let tr = mouse_trajectory_with_rng((12.0, 30.0), (840.0, 510.0), 28.0, &p, &mut r);
2077            assert!(tr.len() >= 8);
2078            let step =
2079                |a: &MousePoint, b: &MousePoint| ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt();
2080            let steps: Vec<f32> = tr.windows(2).map(|w| step(&w[0], &w[1])).collect();
2081            let n = steps.len();
2082            let final_step = steps[n - 1];
2083            let mut sorted = steps.clone();
2084            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
2085            let median = sorted[n / 2];
2086            let max_step = sorted[n - 1];
2087            assert!(
2088                final_step <= max_step + 1e-3,
2089                "seed {seed}: final step {final_step} exceeds max interior {max_step}"
2090            );
2091            assert!(
2092                final_step <= median * 6.0 + 5.0,
2093                "seed {seed}: final step {final_step} is jerk outlier vs median {median}"
2094            );
2095            let last = tr.last().unwrap();
2096            assert!((last.x - 840.0).abs() < 1e-2 && (last.y - 510.0).abs() < 1e-2);
2097        }
2098    }
2099
2100    #[test]
2101    fn keystroke_first_has_no_flight() {
2102        let p = BehaviorProfile {
2103            seed: 42,
2104            ..BehaviorProfile::default()
2105        };
2106        let ks = keystroke_timings("hi", &p);
2107        assert_eq!(ks[0].flight_ms, 0.0);
2108        assert!(ks[1].flight_ms > 0.0);
2109    }
2110
2111    #[test]
2112    fn keystroke_dwell_in_realistic_range() {
2113        let p = BehaviorProfile {
2114            seed: 42,
2115            ..BehaviorProfile::default()
2116        };
2117        let ks = keystroke_timings("the quick brown fox jumps over the lazy dog", &p);
2118        let mean_dwell: f32 = ks.iter().map(|k| k.dwell_ms).sum::<f32>() / ks.len() as f32;
2119        assert!(
2120            (70.0..=150.0).contains(&mean_dwell),
2121            "mean dwell {mean_dwell} outside plausible range"
2122        );
2123    }
2124
2125    #[test]
2126    fn keystroke_flight_scales_with_wpm() {
2127        let slow = BehaviorProfile {
2128            seed: 42,
2129            typing_wpm_mean: 30.0,
2130            ..BehaviorProfile::default()
2131        };
2132        let fast = BehaviorProfile {
2133            seed: 42,
2134            typing_wpm_mean: 70.0,
2135            ..BehaviorProfile::default()
2136        };
2137        let s = keystroke_timings("the quick brown fox jumps over", &slow);
2138        let f = keystroke_timings("the quick brown fox jumps over", &fast);
2139        let mean = |ks: &[KeystrokeTiming]| -> f32 {
2140            ks.iter().skip(1).map(|k| k.flight_ms).sum::<f32>() / (ks.len() - 1) as f32
2141        };
2142        assert!(
2143            mean(&s) > mean(&f),
2144            "30 WPM flight {} should exceed 70 WPM flight {}",
2145            mean(&s),
2146            mean(&f)
2147        );
2148    }
2149
2150    #[test]
2151    fn keystroke_bigram_th_faster_than_dd() {
2152        let mut th_total = 0.0_f32;
2153        let mut dd_total = 0.0_f32;
2154        for seed in 0..50 {
2155            let prof = BehaviorProfile {
2156                seed,
2157                ..BehaviorProfile::default()
2158            };
2159            let th = keystroke_timings("th", &prof);
2160            let dd = keystroke_timings("dd", &prof);
2161            th_total += th[1].flight_ms;
2162            dd_total += dd[1].flight_ms;
2163        }
2164        let th_mean = th_total / 50.0;
2165        let dd_mean = dd_total / 50.0;
2166        assert!(
2167            dd_mean > th_mean * 1.5,
2168            "dd flight {dd_mean} should be > 1.5× th flight {th_mean}"
2169        );
2170    }
2171
2172    #[test]
2173    fn keystroke_deterministic_per_seed() {
2174        let mut rng_a = rand_chacha::ChaCha20Rng::seed_from_u64(7);
2175        let mut rng_b = rand_chacha::ChaCha20Rng::seed_from_u64(7);
2176        let p = BehaviorProfile::default();
2177        let a = keystroke_timings_with_rng("hello world", &p, &mut rng_a);
2178        let b = keystroke_timings_with_rng("hello world", &p, &mut rng_b);
2179        assert_eq!(a, b);
2180    }
2181
2182    #[test]
2183    fn trackpad_burst_decays_to_zero() {
2184        let p = BehaviorProfile {
2185            seed: 42,
2186            scroll_style: ScrollStyle::Trackpad,
2187            ..BehaviorProfile::default()
2188        };
2189        let ticks = wheel_burst(-1000.0, &p);
2190        assert!(ticks.len() > 5);
2191        for t in &ticks {
2192            assert_eq!(t.mode, 0);
2193            assert!(t.delta_y < 0.0);
2194        }
2195        let cum: f32 = ticks.iter().map(|t| t.delta_y).sum();
2196        assert!(
2197            (cum + 1000.0).abs() < 200.0,
2198            "cumulative {cum} not close to -1000"
2199        );
2200        for w in ticks.windows(2) {
2201            let dt = w[1].t_ms - w[0].t_ms;
2202            assert!((dt - 16.0).abs() < 1e-3);
2203        }
2204    }
2205
2206    #[test]
2207    fn wheel_burst_uses_100px_notches() {
2208        let p = BehaviorProfile {
2209            seed: 42,
2210            scroll_style: ScrollStyle::Wheel,
2211            ..BehaviorProfile::default()
2212        };
2213        let ticks = wheel_burst(500.0, &p);
2214        assert_eq!(ticks.len(), 5);
2215        for t in &ticks {
2216            assert_eq!(t.delta_y, 100.0);
2217            assert_eq!(t.mode, 0);
2218        }
2219    }
2220
2221    #[test]
2222    fn wheel_burst_intervals_are_lognormal_distributed() {
2223        let p = BehaviorProfile {
2224            seed: 42,
2225            scroll_style: ScrollStyle::Wheel,
2226            ..BehaviorProfile::default()
2227        };
2228        let ticks = wheel_burst(2000.0, &p);
2229        let intervals: Vec<f32> = ticks.windows(2).map(|w| w[1].t_ms - w[0].t_ms).collect();
2230        let mean = intervals.iter().sum::<f32>() / intervals.len() as f32;
2231        assert!(
2232            (mean - 180.0).abs() < 200.0,
2233            "mean interval {mean} too far from 180 ms"
2234        );
2235        let mut sorted = intervals.clone();
2236        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
2237        sorted.dedup_by(|a, b| (*a - *b).abs() < 1e-3);
2238        assert!(sorted.len() > 5, "only {} distinct intervals", sorted.len());
2239    }
2240
2241    #[test]
2242    fn default_seeds_differ_across_instances() {
2243        let a = BehaviorProfile::default();
2244        let b = BehaviorProfile::default();
2245        assert_ne!(a.seed, b.seed);
2246    }
2247}