Skip to main content

hpx_browser/stealth/
mod.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
10pub mod behavior;
11pub mod browser_presets;
12pub mod gpu_presets;
13
14// Re-export all public items for backward compatibility.
15pub use behavior::*;
16pub use browser_presets::*;
17pub use gpu_presets::*;
18
19// ── GPU catalog ──────────────────────────────────────────────────────
20
21/// A snapshot of a real GPU's WebGL fingerprint as Chrome exposes it.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct GpuProfile {
24    /// `getParameter(VENDOR)` — Chrome always returns "WebKit".
25    pub vendor: String,
26    /// `getParameter(RENDERER)` — Chrome always returns "WebKit WebGL".
27    pub renderer: String,
28    /// `getParameter(VERSION)`.
29    pub version: String,
30    /// `getParameter(SHADING_LANGUAGE_VERSION)`.
31    pub shading_language_version: String,
32    /// `getParameter(UNMASKED_VENDOR_WEBGL)`.
33    pub unmasked_vendor: String,
34    /// `getParameter(UNMASKED_RENDERER_WEBGL)`.
35    pub unmasked_renderer: String,
36    /// `getSupportedExtensions()`.
37    pub extensions: Vec<String>,
38    /// Additional `getParameter()` values keyed by GLenum.
39    pub params: Vec<(u32, serde_json::Value)>,
40    /// `getShaderPrecisionFormat()` values.
41    pub shader_precision: Vec<(u32, u32, [i32; 3])>,
42    /// Distinct WebGL 1.0 surface (version string + extension list).
43    #[serde(default)]
44    pub webgl1: Option<WebGL1Surface>,
45}
46
47/// WebGL 1.0 surface, distinct from the WebGL 2.0 fields on `GpuProfile`.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct WebGL1Surface {
50    pub version: String,
51    pub shading_language_version: String,
52    pub extensions: Vec<String>,
53}
54
55impl Default for GpuProfile {
56    fn default() -> Self {
57        nvidia_rtx_3060_windows()
58    }
59}
60
61// ── Device class ─────────────────────────────────────────────────────
62
63/// Device class driving TLS curve selection, Sec-CH-UA-Mobile, etc.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
65pub enum DeviceClass {
66    #[default]
67    Desktop,
68    MobileAndroid,
69    MobileIOS,
70}
71
72// ── Media device ─────────────────────────────────────────────────────
73
74/// A media device reported by `navigator.mediaDevices.enumerateDevices()`.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct MediaDeviceInfo {
77    pub device_id: String,
78    pub kind: String,
79    pub label: String,
80    pub group_id: String,
81}
82
83// ── StealthProfile ───────────────────────────────────────────────────
84
85/// A complete stealth fingerprint profile.
86///
87/// Start from a preset constructor; to customise, clone a preset, mutate
88/// fields, and call [`StealthProfile::validate`].
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct StealthProfile {
91    // === Identity ===
92    pub user_agent: String,
93    pub browser_name: String,
94    pub browser_version: String,
95    pub os_name: String,
96    pub os_version: String,
97    pub platform: String,
98    pub vendor: String,
99    pub vendor_sub: String,
100    pub product_sub: String,
101    pub app_version: String,
102
103    // === Hardware ===
104    pub screen_width: u32,
105    pub screen_height: u32,
106    pub screen_avail_width: u32,
107    pub screen_avail_height: u32,
108    pub screen_avail_top: u32,
109    pub screen_color_depth: u32,
110    pub device_pixel_ratio: f64,
111    pub cpu_cores: u8,
112    pub device_memory: u8,
113    pub max_touch_points: u8,
114
115    // === GPU / WebGL ===
116    pub webgl_vendor: String,
117    pub webgl_renderer: String,
118    #[serde(default = "default_gpu_profile")]
119    pub gpu_profile: GpuProfile,
120
121    // === Locale ===
122    pub language: String,
123    pub languages: Vec<String>,
124    pub timezone: String,
125
126    // === Client Hints high-entropy values ===
127    #[serde(default = "default_cpu_architecture")]
128    pub cpu_architecture: String,
129    #[serde(default = "default_cpu_bitness")]
130    pub cpu_bitness: String,
131    #[serde(default)]
132    pub platform_version: String,
133    #[serde(default)]
134    pub ua_model: String,
135    #[serde(default)]
136    pub ua_wow64: bool,
137
138    // === Network ===
139    #[serde(default)]
140    pub device_class: DeviceClass,
141    pub tls_impersonate: String,
142    pub connection_effective_type: String,
143    pub connection_rtt: u32,
144    pub connection_downlink: f64,
145
146    // === Plugins ===
147    pub pdf_viewer_enabled: bool,
148    pub plugins_count: u32,
149    pub mime_types_count: u32,
150
151    // === Fingerprint seeds ===
152    pub canvas_seed: u64,
153    pub audio_seed: u64,
154    #[serde(default = "default_audio_sample_rate")]
155    pub audio_sample_rate: u32,
156
157    // === WebAuthn / FedCM ===
158    #[serde(default)]
159    pub has_platform_authenticator: bool,
160    #[serde(default = "default_true")]
161    pub conditional_mediation: bool,
162
163    // === HTTP/3 / QUIC ===
164    #[serde(default)]
165    pub allow_http3: bool,
166
167    // === Media features ===
168    pub prefers_color_scheme: String,
169    pub pointer_type: String,
170    pub hover_capability: String,
171    #[serde(default = "default_color_gamut")]
172    pub color_gamut: String,
173
174    // === Window dimensions ===
175    pub inner_width: u32,
176    pub inner_height: u32,
177    pub outer_width: u32,
178    pub outer_height: u32,
179
180    // === Proxy ===
181    #[serde(default)]
182    pub proxy: Option<String>,
183
184    // === Media devices ===
185    #[serde(default)]
186    pub media_devices: Vec<MediaDeviceInfo>,
187
188    /// Enforce CSP on sub-resource fetches. Defaults to `true`.
189    #[serde(default = "default_true")]
190    pub enforce_csp: bool,
191}
192
193fn default_color_gamut() -> String {
194    "srgb".into()
195}
196fn default_true() -> bool {
197    true
198}
199fn default_gpu_profile() -> GpuProfile {
200    nvidia_rtx_3060_windows()
201}
202fn default_cpu_architecture() -> String {
203    "x86".into()
204}
205fn default_cpu_bitness() -> String {
206    "64".into()
207}
208fn default_audio_sample_rate() -> u32 {
209    44100
210}
211
212impl Default for StealthProfile {
213    fn default() -> Self {
214        chrome_148_windows()
215    }
216}
217
218// ── Validation ───────────────────────────────────────────────────────
219
220impl StealthProfile {
221    /// Validate that all fields are internally consistent.
222    pub fn validate(&self) -> Result<(), Vec<String>> {
223        let mut errors = Vec::new();
224
225        // UA must contain the reduced major version (Chrome) or short version (Firefox)
226        let ua_major = self.browser_version.split('.').next().unwrap_or("");
227        let chrome_form = format!("{ua_major}.0.0.0");
228        let firefox_form = format!("{ua_major}.0");
229        if !self.user_agent.contains(&chrome_form) && !self.user_agent.contains(&firefox_form) {
230            errors.push(format!(
231                "UA '{}' doesn't contain reduced major version '{}' or '{}'",
232                self.user_agent, chrome_form, firefox_form
233            ));
234        }
235
236        // Platform must match OS
237        match self.os_name.as_str() {
238            "Windows" if self.platform != "Win32" => {
239                errors.push(format!("Windows OS but platform is '{}'", self.platform));
240            }
241            "macOS" if self.platform != "MacIntel" => {
242                errors.push(format!("macOS but platform is '{}'", self.platform));
243            }
244            "Linux" if !self.platform.starts_with("Linux") => {
245                errors.push(format!("Linux OS but platform is '{}'", self.platform));
246            }
247            _ => {}
248        }
249
250        // Touch points: desktop = 0, mobile > 0
251        if self.max_touch_points > 0 && self.screen_width > 1024 && self.pointer_type == "fine" {
252            errors.push("Touch points > 0 but desktop pointer type".into());
253        }
254
255        // GPU vendor must match renderer
256        if self.webgl_renderer.contains("NVIDIA") && !self.webgl_vendor.contains("NVIDIA") {
257            errors.push("WebGL renderer is NVIDIA but vendor doesn't match".into());
258        }
259        if self.webgl_renderer.contains("Intel") && !self.webgl_vendor.contains("Intel") {
260            errors.push("WebGL renderer is Intel but vendor doesn't match".into());
261        }
262        if self.webgl_renderer.contains("Apple") && !self.webgl_vendor.contains("Apple") {
263            errors.push("WebGL renderer is Apple but vendor doesn't match".into());
264        }
265
266        // Apple GPU only on macOS/iOS
267        if self.webgl_renderer.contains("Apple")
268            && !matches!(self.os_name.as_str(), "macOS" | "iOS")
269        {
270            errors.push("Apple GPU on non-Apple OS".into());
271        }
272
273        // Screen dimensions sanity
274        if self.screen_width == 0 || self.screen_height == 0 {
275            errors.push("Screen dimensions cannot be zero".into());
276        }
277        if self.inner_width > self.screen_width {
278            errors.push("inner_width > screen_width".into());
279        }
280        if self.outer_width < self.inner_width {
281            errors.push("outer_width < inner_width".into());
282        }
283
284        // CPU/memory sanity
285        if self.cpu_cores == 0 || self.cpu_cores > 128 {
286            errors.push(format!("Unrealistic cpu_cores: {}", self.cpu_cores));
287        }
288        if self.device_memory == 0 && self.os_name != "iOS" {
289            errors.push(format!("Unrealistic device_memory: {}", self.device_memory));
290        }
291
292        // Language must be in languages list
293        if !self.languages.contains(&self.language) {
294            errors.push(format!(
295                "language '{}' not in languages {:?}",
296                self.language, self.languages
297            ));
298        }
299
300        // Client Hints consistency
301        if !matches!(self.cpu_architecture.as_str(), "x86" | "arm" | "") {
302            errors.push(format!(
303                "cpu_architecture must be 'x86', 'arm', or '' (got '{}')",
304                self.cpu_architecture
305            ));
306        }
307        if !matches!(self.cpu_bitness.as_str(), "64" | "32") {
308            errors.push(format!(
309                "cpu_bitness must be '64' or '32' (got '{}')",
310                self.cpu_bitness
311            ));
312        }
313        if self.ua_wow64 && (self.os_name != "Windows" || self.cpu_bitness != "32") {
314            errors.push(format!(
315                "ua_wow64=true requires os_name=Windows and cpu_bitness=32 (got {} / {})",
316                self.os_name, self.cpu_bitness
317            ));
318        }
319        if self.os_name == "Linux" && !self.platform_version.is_empty() {
320            errors.push(format!(
321                "Chrome on Linux must report empty platform_version (got '{}')",
322                self.platform_version
323            ));
324        }
325        if self.cpu_architecture == "arm"
326            && !matches!(
327                self.os_name.as_str(),
328                "macOS" | "Android" | "ChromeOS" | "iOS"
329            )
330        {
331            errors.push(format!(
332                "cpu_architecture=arm only on macOS/Android/ChromeOS/iOS (got '{}')",
333                self.os_name
334            ));
335        }
336        if !self.ua_model.is_empty() && self.max_touch_points == 0 {
337            errors.push(format!(
338                "ua_model='{}' on a desktop (max_touch_points=0) profile",
339                self.ua_model
340            ));
341        }
342        if !matches!(self.audio_sample_rate, 44100 | 48000 | 96000 | 192000) {
343            errors.push(format!(
344                "audio_sample_rate must be one of {{44100, 48000, 96000, 192000}} (got {})",
345                self.audio_sample_rate
346            ));
347        }
348
349        if errors.is_empty() {
350            Ok(())
351        } else {
352            Err(errors)
353        }
354    }
355}
356
357// ── Utility functions ────────────────────────────────────────────────
358
359/// Create a profile with custom locale/timezone from a base profile.
360pub fn with_locale(
361    mut base: StealthProfile,
362    language: &str,
363    languages: &[&str],
364    timezone: &str,
365) -> StealthProfile {
366    base.language = language.into();
367    base.languages = languages.iter().map(|s| (*s).to_string()).collect();
368    base.timezone = timezone.into();
369    base
370}
371
372/// Random desktop profile (picks randomly from Chrome presets).
373pub fn random_desktop() -> StealthProfile {
374    use rand::RngExt;
375    let mut rng = rand::rng();
376    let mut profile = match rng.random_range(0..3u32) {
377        0 => chrome_148_windows(),
378        1 => chrome_148_macos(),
379        _ => chrome_148_linux(),
380    };
381    profile.canvas_seed = rng.random();
382    profile.audio_seed = rng.random();
383    profile
384}
385
386/// Apple Silicon Chrome 148 profile sampler.
387///
388/// Returns one variant of `chrome_148_macos` with screen geometry, core
389/// count, RAM, and fingerprint seeds independently sampled from
390/// realistic Apple Silicon distributions.
391pub fn chrome_148_macos_sampled() -> StealthProfile {
392    chrome_148_macos_sampled_with_rng(&mut rand::rng())
393}
394
395/// As [`chrome_148_macos_sampled`] but takes a caller-supplied RNG.
396pub fn chrome_148_macos_sampled_with_rng(rng: &mut impl rand::RngExt) -> StealthProfile {
397    let mut p = chrome_148_macos();
398
399    type ChipConfig = (
400        &'static [u8],
401        &'static [u8],
402        &'static [(u32, u32, u32)],
403        GpuProfile,
404    );
405    let chip_idx = rng.random_range(0..3u32);
406    let (cores_pool, ram_pool, screens, gpu): ChipConfig = match chip_idx {
407        0 => (
408            &[8],
409            &[8, 16, 24],
410            &[(1512, 982, 949), (1728, 1117, 1010)],
411            apple_m3_macos(),
412        ),
413        1 => (
414            &[11, 12],
415            &[18, 36],
416            &[(1800, 1169, 1100), (2056, 1329, 1253)],
417            apple_m3_pro_macos(),
418        ),
419        _ => (
420            &[14, 16],
421            &[36, 48],
422            &[(1800, 1169, 1100), (2056, 1329, 1253)],
423            apple_m3_max_macos(),
424        ),
425    };
426
427    p.cpu_cores = cores_pool[rng.random_range(0..cores_pool.len())];
428    p.device_memory = ram_pool[rng.random_range(0..ram_pool.len())];
429
430    let (w, h, ah) = screens[rng.random_range(0..screens.len())];
431    p.screen_width = w;
432    p.screen_height = h;
433    p.screen_avail_width = w;
434    p.screen_avail_height = ah;
435    p.inner_width = w;
436    p.inner_height = h.saturating_sub(111);
437    p.outer_width = w;
438    p.outer_height = h;
439
440    p.gpu_profile = gpu;
441    p.webgl_renderer = p.gpu_profile.unmasked_renderer.clone();
442
443    p.canvas_seed = rng.random();
444    p.audio_seed = rng.random();
445
446    debug_assert!(
447        p.validate().is_ok(),
448        "chrome_148_macos_sampled produced an invalid profile: {:?}",
449        p.validate()
450    );
451
452    p
453}
454
455// ── Compat presets module (for headers.rs tests) ─────────────────────
456
457/// Test presets re-exported as a module for backward compat.
458#[cfg(test)]
459pub mod presets {
460    use super::*;
461
462    pub fn chrome_147_macos() -> StealthProfile {
463        chrome_148_macos()
464    }
465    pub fn chrome_147_windows() -> StealthProfile {
466        chrome_148_windows()
467    }
468    pub fn chrome_147_linux() -> StealthProfile {
469        chrome_148_linux()
470    }
471    pub fn firefox_135_macos() -> StealthProfile {
472        super::firefox_135_macos()
473    }
474    pub fn safari_ios_18() -> StealthProfile {
475        iphone_15_pro_safari_18()
476    }
477    pub fn pixel_9_pro_chrome_148() -> StealthProfile {
478        super::pixel_9_pro_chrome_148()
479    }
480}
481
482// ── Tests ────────────────────────────────────────────────────────────
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn chrome_148_windows_validates() {
490        let p = chrome_148_windows();
491        assert!(p.validate().is_ok(), "{:?}", p.validate());
492    }
493
494    #[test]
495    fn chrome_148_macos_validates() {
496        let p = chrome_148_macos();
497        assert!(p.validate().is_ok(), "{:?}", p.validate());
498    }
499
500    #[test]
501    fn chrome_148_linux_validates() {
502        let p = chrome_148_linux();
503        assert!(p.validate().is_ok(), "{:?}", p.validate());
504    }
505
506    #[test]
507    fn chrome_148_ru_validates() {
508        let p = chrome_148_ru();
509        assert!(p.validate().is_ok(), "{:?}", p.validate());
510    }
511
512    #[test]
513    fn chrome_148_cn_validates() {
514        let p = chrome_148_cn();
515        assert!(p.validate().is_ok(), "{:?}", p.validate());
516    }
517
518    #[test]
519    fn firefox_135_macos_validates() {
520        let p = firefox_135_macos();
521        assert!(p.validate().is_ok(), "{:?}", p.validate());
522        assert_eq!(p.browser_name, "Firefox");
523        assert_eq!(p.vendor, "");
524        assert_eq!(p.product_sub, "20100101");
525        assert!(p.user_agent.contains("rv:135.0"));
526        assert!(p.user_agent.contains("Firefox/135.0"));
527        assert!(!p.user_agent.contains("Chrome"));
528    }
529
530    #[test]
531    fn firefox_135_windows_validates() {
532        let p = firefox_135_windows();
533        assert!(p.validate().is_ok(), "{:?}", p.validate());
534        assert!(p.user_agent.contains("Firefox/135.0"));
535    }
536
537    #[test]
538    fn firefox_135_linux_validates() {
539        let p = firefox_135_linux();
540        assert!(p.validate().is_ok(), "{:?}", p.validate());
541        assert!(p.user_agent.contains("Firefox/135.0"));
542    }
543
544    #[test]
545    fn pixel_9_pro_validates() {
546        let p = pixel_9_pro_chrome_148();
547        assert!(p.validate().is_ok(), "{:?}", p.validate());
548    }
549
550    #[test]
551    fn iphone_15_pro_validates() {
552        let p = iphone_15_pro_safari_18();
553        assert!(p.validate().is_ok(), "{:?}", p.validate());
554    }
555
556    #[test]
557    fn http3_disabled_by_default_on_all_presets() {
558        for profile in [
559            chrome_148_windows(),
560            chrome_148_macos(),
561            chrome_148_linux(),
562            chrome_148_ru(),
563            chrome_148_cn(),
564            chrome_148_de(),
565            chrome_148_jp(),
566            firefox_135_macos(),
567            firefox_135_windows(),
568            firefox_135_linux(),
569        ] {
570            assert!(
571                !profile.allow_http3,
572                "Profile sets allow_http3=true: {}",
573                profile.user_agent
574            );
575        }
576    }
577
578    #[test]
579    fn firefox_webgl_is_masked() {
580        for profile in [
581            firefox_135_macos(),
582            firefox_135_windows(),
583            firefox_135_linux(),
584        ] {
585            assert_eq!(profile.webgl_vendor, "Mozilla");
586            assert_eq!(profile.webgl_renderer, "Mozilla");
587        }
588    }
589
590    #[test]
591    fn random_desktop_validates() {
592        for _ in 0..10 {
593            let p = random_desktop();
594            assert!(p.validate().is_ok(), "{:?}", p.validate());
595        }
596    }
597
598    #[test]
599    fn random_desktop_diversity() {
600        use std::collections::HashSet;
601        let mut names = HashSet::new();
602        for _ in 0..30 {
603            let p = random_desktop();
604            names.insert(p.browser_name.clone());
605        }
606        // All Chrome presets share browser_name="Chrome", so diversity
607        // comes from screen/seed variation. At least 1 name expected.
608        assert!(!names.is_empty());
609    }
610
611    #[test]
612    fn invalid_profile_detected() {
613        let mut p = chrome_148_windows();
614        p.platform = "MacIntel".into();
615        assert!(p.validate().is_err());
616    }
617
618    #[test]
619    fn invalid_gpu_os_mismatch() {
620        let mut p = chrome_148_windows();
621        p.webgl_renderer =
622            "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)".into();
623        p.webgl_vendor = "Google Inc. (Apple)".into();
624        assert!(p.validate().is_err());
625    }
626
627    #[test]
628    fn ua_contains_version() {
629        let p = chrome_148_windows();
630        assert!(p.user_agent.contains("148.0.0.0"));
631        assert_eq!(p.browser_version, "148.0.7778.168");
632    }
633
634    #[test]
635    fn serialization_roundtrip() {
636        let p = chrome_148_windows();
637        let json = serde_json::to_string(&p).unwrap();
638        let deserialized: StealthProfile = serde_json::from_str(&json).unwrap();
639        assert_eq!(p.user_agent, deserialized.user_agent);
640        assert_eq!(p.screen_width, deserialized.screen_width);
641    }
642
643    #[test]
644    fn macos_sampler_produces_valid_profiles() {
645        for _ in 0..200 {
646            let p = chrome_148_macos_sampled();
647            p.validate()
648                .unwrap_or_else(|e| panic!("invalid sampled profile: {e:?}"));
649            assert!(matches!(p.screen_width, 1512 | 1728 | 1800 | 2056));
650            assert!(matches!(p.cpu_cores, 8 | 11 | 12 | 14 | 16));
651            assert!(matches!(p.device_memory, 8 | 16 | 18 | 24 | 36 | 48));
652            assert_eq!(p.device_pixel_ratio, 2.0);
653            assert_eq!(p.audio_sample_rate, 48000);
654            assert_eq!(p.cpu_architecture, "arm");
655            assert_eq!(p.platform, "MacIntel");
656            assert_eq!(p.inner_height + 111, p.screen_height);
657        }
658    }
659
660    #[test]
661    fn macos_sampler_keeps_cross_api_consistency() {
662        for _ in 0..50 {
663            let p = chrome_148_macos_sampled();
664            let r = &p.gpu_profile.unmasked_renderer;
665            match p.cpu_cores {
666                8 => {
667                    assert!(r.contains("Apple M3,"));
668                    assert!(matches!(p.device_memory, 8 | 16 | 24));
669                }
670                11 | 12 => {
671                    assert!(r.contains("Apple M3 Pro"));
672                    assert!(matches!(p.device_memory, 18 | 36));
673                }
674                14 | 16 => {
675                    assert!(r.contains("Apple M3 Max"));
676                    assert!(matches!(p.device_memory, 36 | 48));
677                }
678                other => panic!("unexpected cpu_cores {other}"),
679            }
680            assert_eq!(p.webgl_renderer, *r);
681        }
682    }
683
684    // ── Behavior tests ─────────────────────────────────────────────
685
686    use rand_chacha::rand_core::SeedableRng;
687
688    fn fixed_rng() -> rand_chacha::ChaCha20Rng {
689        rand_chacha::ChaCha20Rng::seed_from_u64(42)
690    }
691
692    #[test]
693    fn behavior_profile_defaults_are_sensible() {
694        let p = BehaviorProfile::default();
695        assert!((30.0..=80.0).contains(&p.typing_wpm_mean));
696        assert!((130.0..=220.0).contains(&p.fitts_b));
697        assert_eq!(p.handedness, Handedness::Right);
698    }
699
700    #[test]
701    fn rng_for_is_deterministic_per_seed() {
702        let p = BehaviorProfile {
703            seed: 99,
704            ..BehaviorProfile::default()
705        };
706        let mut a = p.rng_for(123);
707        let mut b = p.rng_for(123);
708        use rand::RngExt;
709        assert_eq!(a.random::<u64>(), b.random::<u64>());
710    }
711
712    #[test]
713    fn rng_for_differs_across_salts() {
714        let p = BehaviorProfile {
715            seed: 99,
716            ..BehaviorProfile::default()
717        };
718        let mut a = p.rng_for(1);
719        let mut b = p.rng_for(2);
720        use rand::RngExt;
721        assert_ne!(a.random::<u64>(), b.random::<u64>());
722    }
723
724    #[test]
725    fn mouse_trajectory_starts_at_from_and_ends_at_to() {
726        let p = BehaviorProfile {
727            seed: 42,
728            ..BehaviorProfile::default()
729        };
730        let pts = mouse_trajectory((100.0, 100.0), (500.0, 400.0), 50.0, &p);
731        assert!(pts.len() > 5);
732        let first = pts[0];
733        let last = pts[pts.len() - 1];
734        assert!((first.x - 100.0).abs() < 10.0, "first x={}", first.x);
735        assert!((first.y - 100.0).abs() < 10.0, "first y={}", first.y);
736        assert_eq!(last.x, 500.0);
737        assert_eq!(last.y, 400.0);
738    }
739
740    #[test]
741    fn mouse_trajectory_obeys_fitts_law_total_time() {
742        let p = BehaviorProfile {
743            seed: 42,
744            ..BehaviorProfile::default()
745        };
746        let pts = mouse_trajectory((0.0, 0.0), (500.0, 0.0), 50.0, &p);
747        let last_t = pts[pts.len() - 1].t_ms;
748        assert!(
749            (700.0..=950.0).contains(&last_t),
750            "expected ~805 ms, got {last_t}"
751        );
752    }
753
754    #[test]
755    fn mouse_trajectory_uses_8ms_sample_rate() {
756        let p = BehaviorProfile {
757            seed: 42,
758            ..BehaviorProfile::default()
759        };
760        let pts = mouse_trajectory((0.0, 0.0), (200.0, 0.0), 30.0, &p);
761        for w in pts.windows(2) {
762            let dt = w[1].t_ms - w[0].t_ms;
763            assert!((dt - 8.0).abs() < 1e-3, "gap {} not 8 ms", dt);
764        }
765    }
766
767    #[test]
768    fn mouse_trajectory_has_velocity_diversity() {
769        let p = BehaviorProfile {
770            seed: 42,
771            ..BehaviorProfile::default()
772        };
773        let mut rng = fixed_rng();
774        let pts = mouse_trajectory_with_rng((0.0, 0.0), (600.0, 400.0), 40.0, &p, &mut rng);
775        let speeds: Vec<f32> = pts
776            .windows(2)
777            .map(|w| ((w[1].x - w[0].x).powi(2) + (w[1].y - w[0].y).powi(2)).sqrt())
778            .collect();
779        let mean = speeds.iter().sum::<f32>() / speeds.len() as f32;
780        let var = speeds.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / speeds.len() as f32;
781        let std = var.sqrt();
782        let cv = std / mean.max(1e-3);
783        assert!(cv > 0.4, "speed CV too low: {cv}");
784    }
785
786    #[test]
787    fn mouse_trajectory_deterministic_per_seed() {
788        let p = BehaviorProfile {
789            seed: 123,
790            ..BehaviorProfile::default()
791        };
792        let mut r1 = p.rng_for(1);
793        let mut r2 = p.rng_for(1);
794        let a = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r1);
795        let b = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r2);
796        assert_eq!(a.len(), b.len());
797        for (pa, pb) in a.iter().zip(b.iter()) {
798            assert_eq!(pa, pb);
799        }
800    }
801
802    #[test]
803    fn mouse_trajectory_no_endpoint_jerk_spike() {
804        for seed in 0..40u64 {
805            let p = BehaviorProfile {
806                seed,
807                ..BehaviorProfile::default()
808            };
809            let mut r = p.rng_for(2);
810            let tr = mouse_trajectory_with_rng((12.0, 30.0), (840.0, 510.0), 28.0, &p, &mut r);
811            assert!(tr.len() >= 8);
812            let step =
813                |a: &MousePoint, b: &MousePoint| ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt();
814            let steps: Vec<f32> = tr.windows(2).map(|w| step(&w[0], &w[1])).collect();
815            let n = steps.len();
816            let final_step = steps[n - 1];
817            let mut sorted = steps.clone();
818            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
819            let median = sorted[n / 2];
820            let max_step = sorted[n - 1];
821            assert!(
822                final_step <= max_step + 1e-3,
823                "seed {seed}: final step {final_step} exceeds max interior {max_step}"
824            );
825            assert!(
826                final_step <= median * 6.0 + 5.0,
827                "seed {seed}: final step {final_step} is jerk outlier vs median {median}"
828            );
829            let last = tr.last().unwrap();
830            assert!((last.x - 840.0).abs() < 1e-2 && (last.y - 510.0).abs() < 1e-2);
831        }
832    }
833
834    #[test]
835    fn keystroke_first_has_no_flight() {
836        let p = BehaviorProfile {
837            seed: 42,
838            ..BehaviorProfile::default()
839        };
840        let ks = keystroke_timings("hi", &p);
841        assert_eq!(ks[0].flight_ms, 0.0);
842        assert!(ks[1].flight_ms > 0.0);
843    }
844
845    #[test]
846    fn keystroke_dwell_in_realistic_range() {
847        let p = BehaviorProfile {
848            seed: 42,
849            ..BehaviorProfile::default()
850        };
851        let ks = keystroke_timings("the quick brown fox jumps over the lazy dog", &p);
852        let mean_dwell: f32 = ks.iter().map(|k| k.dwell_ms).sum::<f32>() / ks.len() as f32;
853        assert!(
854            (70.0..=150.0).contains(&mean_dwell),
855            "mean dwell {mean_dwell} outside plausible range"
856        );
857    }
858
859    #[test]
860    fn keystroke_flight_scales_with_wpm() {
861        let slow = BehaviorProfile {
862            seed: 42,
863            typing_wpm_mean: 30.0,
864            ..BehaviorProfile::default()
865        };
866        let fast = BehaviorProfile {
867            seed: 42,
868            typing_wpm_mean: 70.0,
869            ..BehaviorProfile::default()
870        };
871        let s = keystroke_timings("the quick brown fox jumps over", &slow);
872        let f = keystroke_timings("the quick brown fox jumps over", &fast);
873        let mean = |ks: &[KeystrokeTiming]| -> f32 {
874            ks.iter().skip(1).map(|k| k.flight_ms).sum::<f32>() / (ks.len() - 1) as f32
875        };
876        assert!(
877            mean(&s) > mean(&f),
878            "30 WPM flight {} should exceed 70 WPM flight {}",
879            mean(&s),
880            mean(&f)
881        );
882    }
883
884    #[test]
885    fn keystroke_bigram_th_faster_than_dd() {
886        let mut th_total = 0.0_f32;
887        let mut dd_total = 0.0_f32;
888        for seed in 0..50 {
889            let prof = BehaviorProfile {
890                seed,
891                ..BehaviorProfile::default()
892            };
893            let th = keystroke_timings("th", &prof);
894            let dd = keystroke_timings("dd", &prof);
895            th_total += th[1].flight_ms;
896            dd_total += dd[1].flight_ms;
897        }
898        let th_mean = th_total / 50.0;
899        let dd_mean = dd_total / 50.0;
900        assert!(
901            dd_mean > th_mean * 1.5,
902            "dd flight {dd_mean} should be > 1.5× th flight {th_mean}"
903        );
904    }
905
906    #[test]
907    fn keystroke_deterministic_per_seed() {
908        let mut rng_a = rand_chacha::ChaCha20Rng::seed_from_u64(7);
909        let mut rng_b = rand_chacha::ChaCha20Rng::seed_from_u64(7);
910        let p = BehaviorProfile::default();
911        let a = keystroke_timings_with_rng("hello world", &p, &mut rng_a);
912        let b = keystroke_timings_with_rng("hello world", &p, &mut rng_b);
913        assert_eq!(a, b);
914    }
915
916    #[test]
917    fn trackpad_burst_decays_to_zero() {
918        let p = BehaviorProfile {
919            seed: 42,
920            scroll_style: ScrollStyle::Trackpad,
921            ..BehaviorProfile::default()
922        };
923        let ticks = wheel_burst(-1000.0, &p);
924        assert!(ticks.len() > 5);
925        for t in &ticks {
926            assert_eq!(t.mode, 0);
927            assert!(t.delta_y < 0.0);
928        }
929        let cum: f32 = ticks.iter().map(|t| t.delta_y).sum();
930        assert!(
931            (cum + 1000.0).abs() < 200.0,
932            "cumulative {cum} not close to -1000"
933        );
934        for w in ticks.windows(2) {
935            let dt = w[1].t_ms - w[0].t_ms;
936            assert!((dt - 16.0).abs() < 1e-3);
937        }
938    }
939
940    #[test]
941    fn wheel_burst_uses_100px_notches() {
942        let p = BehaviorProfile {
943            seed: 42,
944            scroll_style: ScrollStyle::Wheel,
945            ..BehaviorProfile::default()
946        };
947        let ticks = wheel_burst(500.0, &p);
948        assert_eq!(ticks.len(), 5);
949        for t in &ticks {
950            assert_eq!(t.delta_y, 100.0);
951            assert_eq!(t.mode, 0);
952        }
953    }
954
955    #[test]
956    fn wheel_burst_intervals_are_lognormal_distributed() {
957        let p = BehaviorProfile {
958            seed: 42,
959            scroll_style: ScrollStyle::Wheel,
960            ..BehaviorProfile::default()
961        };
962        let ticks = wheel_burst(2000.0, &p);
963        let intervals: Vec<f32> = ticks.windows(2).map(|w| w[1].t_ms - w[0].t_ms).collect();
964        let mean = intervals.iter().sum::<f32>() / intervals.len() as f32;
965        assert!(
966            (mean - 180.0).abs() < 200.0,
967            "mean interval {mean} too far from 180 ms"
968        );
969        let mut sorted = intervals.clone();
970        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
971        sorted.dedup_by(|a, b| (*a - *b).abs() < 1e-3);
972        assert!(sorted.len() > 5, "only {} distinct intervals", sorted.len());
973    }
974
975    #[test]
976    fn default_seeds_differ_across_instances() {
977        let a = BehaviorProfile::default();
978        let b = BehaviorProfile::default();
979        assert_ne!(a.seed, b.seed);
980    }
981}