1use serde::{Deserialize, Serialize};
27
28use crate::freshness::signature_hash;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
42#[allow(clippy::struct_excessive_bools)] pub struct StealthConfig {
44 pub spoof_navigator: bool,
46 pub randomize_webgl: bool,
48 pub randomize_canvas: bool,
50 pub human_behavior: bool,
52 pub protect_cdp: bool,
54}
55
56impl Default for StealthConfig {
57 fn default() -> Self {
58 Self {
59 spoof_navigator: true,
60 randomize_webgl: true,
61 randomize_canvas: true,
62 human_behavior: true,
63 protect_cdp: true,
64 }
65 }
66}
67
68impl StealthConfig {
69 #[must_use]
71 pub fn paranoid() -> Self {
72 Self::default()
73 }
74
75 #[must_use]
77 pub const fn minimal() -> Self {
78 Self {
79 spoof_navigator: true,
80 randomize_webgl: false,
81 randomize_canvas: false,
82 human_behavior: false,
83 protect_cdp: true,
84 }
85 }
86
87 #[must_use]
89 pub const fn disabled() -> Self {
90 Self {
91 spoof_navigator: false,
92 randomize_webgl: false,
93 randomize_canvas: false,
94 human_behavior: false,
95 protect_cdp: false,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct NavigatorProfile {
118 pub user_agent: String,
120 pub platform: String,
122 pub vendor: String,
124 pub hardware_concurrency: u8,
126 pub device_memory: u8,
128 pub max_touch_points: u8,
130 pub webgl_vendor: String,
132 pub webgl_renderer: String,
134}
135
136impl NavigatorProfile {
137 #[must_use]
139 pub fn windows_chrome() -> Self {
140 Self {
141 user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
142 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
143 .to_string(),
144 platform: "Win32".to_string(),
145 vendor: "Google Inc.".to_string(),
146 hardware_concurrency: 8,
147 device_memory: 8,
148 max_touch_points: 0,
149 webgl_vendor: "Google Inc. (NVIDIA)".to_string(),
150 webgl_renderer:
151 "ANGLE (NVIDIA, NVIDIA GeForce GTX 1650 Direct3D11 vs_5_0 ps_5_0, D3D11)"
152 .to_string(),
153 }
154 }
155
156 #[must_use]
158 pub fn mac_chrome() -> Self {
159 Self {
160 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
161 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
162 .to_string(),
163 platform: "MacIntel".to_string(),
164 vendor: "Google Inc.".to_string(),
165 hardware_concurrency: 8,
166 device_memory: 8,
167 max_touch_points: 0,
168 webgl_vendor: "Google Inc. (Intel)".to_string(),
169 webgl_renderer: "ANGLE (Intel, Apple M1 Pro, OpenGL 4.1)".to_string(),
170 }
171 }
172
173 #[must_use]
175 pub fn linux_chrome() -> Self {
176 Self {
177 user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
178 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
179 .to_string(),
180 platform: "Linux x86_64".to_string(),
181 vendor: "Google Inc.".to_string(),
182 hardware_concurrency: 4,
183 device_memory: 4,
184 max_touch_points: 0,
185 webgl_vendor: "Mesa/X.org".to_string(),
186 webgl_renderer: "llvmpipe (LLVM 15.0.7, 256 bits)".to_string(),
187 }
188 }
189
190 #[must_use]
208 pub fn signature(&self) -> String {
209 use std::fmt::Write as _;
210 let mut buf = String::new();
211 let _ = writeln!(&mut buf, "ua={}", self.user_agent);
212 let _ = writeln!(&mut buf, "platform={}", self.platform);
213 let _ = writeln!(&mut buf, "vendor={}", self.vendor);
214 let _ = writeln!(&mut buf, "hw={}", self.hardware_concurrency);
215 let _ = writeln!(&mut buf, "dm={}", self.device_memory);
216 let _ = writeln!(&mut buf, "tp={}", self.max_touch_points);
217 let _ = writeln!(&mut buf, "webgl_vendor={}", self.webgl_vendor);
218 let _ = write!(&mut buf, "webgl_renderer={}", self.webgl_renderer);
219 signature_hash(&[buf.as_str()])
220 }
221}
222
223impl Default for NavigatorProfile {
224 fn default() -> Self {
225 Self::mac_chrome()
226 }
227}
228
229pub struct StealthProfile {
245 config: StealthConfig,
246 navigator: NavigatorProfile,
247}
248
249impl StealthProfile {
250 #[must_use]
252 pub const fn new(config: StealthConfig, navigator: NavigatorProfile) -> Self {
253 Self { config, navigator }
254 }
255
256 #[must_use]
261 pub fn injection_script(&self) -> String {
262 let mut parts: Vec<String> = Vec::new();
263
264 if self.config.spoof_navigator {
265 parts.push(self.navigator_spoof_script());
266 }
267
268 if self.config.randomize_webgl {
269 parts.push(self.webgl_spoof_script());
270 }
271
272 if self.config.spoof_navigator {
275 parts.push(Self::chrome_object_script());
276 parts.push(self.user_agent_data_script());
277 }
278
279 if parts.is_empty() {
280 return String::new();
281 }
282
283 format!(
285 "(function() {{\n 'use strict';\n{}\n}})();",
286 parts.join("\n\n")
287 )
288 }
289
290 fn navigator_spoof_script(&self) -> String {
293 let nav = &self.navigator;
294
295 format!(
298 r" // --- Navigator spoofing ---
299 (function() {{
300 const defineReadOnly = (target, prop, value) => {{
301 try {{
302 Object.defineProperty(target, prop, {{
303 get: () => value,
304 enumerable: true,
305 configurable: false,
306 }});
307 }} catch (_) {{}}
308 }};
309
310 // Remove the webdriver flag at both the prototype and instance levels.
311 // Cloudflare and pixelscan probe Navigator.prototype directly via
312 // Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver').
313 // In real Chrome the property is enumerable:false — matching that is
314 // essential; enumerable:true is a Turnstile detection signal.
315 // configurable:true is kept so polyfills don't throw on a second
316 // defineProperty call.
317 try {{
318 Object.defineProperty(Navigator.prototype, 'webdriver', {{
319 get: () => undefined,
320 enumerable: false,
321 configurable: true,
322 }});
323 }} catch (_) {{}}
324 defineReadOnly(navigator, 'webdriver', undefined);
325
326 // Platform / identity
327 defineReadOnly(navigator, 'platform', {platform:?});
328 defineReadOnly(navigator, 'userAgent', {user_agent:?});
329 defineReadOnly(navigator, 'vendor', {vendor:?});
330 defineReadOnly(navigator, 'hardwareConcurrency', {hwc});
331 defineReadOnly(navigator, 'deviceMemory', {dm});
332 defineReadOnly(navigator, 'maxTouchPoints', {mtp});
333
334 // Permissions API — real browsers resolve 'notifications' as 'default'
335 if (navigator.permissions && navigator.permissions.query) {{
336 const origQuery = navigator.permissions.query.bind(navigator.permissions);
337 navigator.permissions.query = (params) => {{
338 if (params && params.name === 'notifications') {{
339 return Promise.resolve({{ state: Notification.permission, onchange: null }});
340 }}
341 return origQuery(params);
342 }};
343 }}
344 }})();",
345 platform = nav.platform,
346 user_agent = nav.user_agent,
347 vendor = nav.vendor,
348 hwc = nav.hardware_concurrency,
349 dm = nav.device_memory,
350 mtp = nav.max_touch_points,
351 )
352 }
353
354 fn chrome_object_script() -> String {
355 r" // --- window.chrome object spoofing ---
359 (function() {
360 if (!window.chrome) {
361 Object.defineProperty(window, 'chrome', {
362 value: {},
363 enumerable: true,
364 configurable: false,
365 writable: false,
366 });
367 }
368 const chrome = window.chrome;
369 // chrome.runtime — checked by Turnstile; needs at least an object with
370 // id and connect stubs to pass duck-type checks.
371 if (!chrome.runtime) {
372 chrome.runtime = {
373 id: undefined,
374 connect: () => {},
375 sendMessage: () => {},
376 onMessage: { addListener: () => {}, removeListener: () => {} },
377 };
378 }
379 // chrome.csi and chrome.loadTimes — legacy APIs present in real Chrome.
380 if (!chrome.csi) {
381 chrome.csi = () => ({
382 startE: Date.now(),
383 onloadT: Date.now(),
384 pageT: 0,
385 tran: 15,
386 });
387 }
388 if (!chrome.loadTimes) {
389 chrome.loadTimes = () => ({
390 requestTime: Date.now() / 1000,
391 startLoadTime: Date.now() / 1000,
392 commitLoadTime: Date.now() / 1000,
393 finishDocumentLoadTime: Date.now() / 1000,
394 finishLoadTime: Date.now() / 1000,
395 firstPaintTime: Date.now() / 1000,
396 firstPaintAfterLoadTime: 0,
397 navigationType: 'Other',
398 wasFetchedViaSpdy: false,
399 wasNpnNegotiated: true,
400 npnNegotiatedProtocol: 'h2',
401 wasAlternateProtocolAvailable: false,
402 connectionInfo: 'h2',
403 });
404 }
405 })();"
406 .to_string()
407 }
408
409 fn user_agent_data_script(&self) -> String {
410 let nav = &self.navigator;
411 let version = nav
415 .user_agent
416 .split("Chrome/")
417 .nth(1)
418 .and_then(|s| s.split('.').next())
419 .unwrap_or("131");
420 let mobile = nav.max_touch_points > 0;
421 let platform = if nav.platform.contains("Win") {
422 "Windows"
423 } else if nav.platform.contains("Mac") {
424 "macOS"
425 } else {
426 "Linux"
427 };
428
429 format!(
430 r" // --- navigator.userAgentData spoofing ---
431 (function() {{
432 const uaData = {{
433 brands: [
434 {{ brand: 'Google Chrome', version: '{version}' }},
435 {{ brand: 'Chromium', version: '{version}' }},
436 {{ brand: 'Not=A?Brand', version: '99' }},
437 ],
438 mobile: {mobile},
439 platform: '{platform}',
440 getHighEntropyValues: (hints) => Promise.resolve({{
441 brands: [
442 {{ brand: 'Google Chrome', version: '{version}' }},
443 {{ brand: 'Chromium', version: '{version}' }},
444 {{ brand: 'Not=A?Brand', version: '99' }},
445 ],
446 mobile: {mobile},
447 platform: '{platform}',
448 architecture: 'x86',
449 bitness: '64',
450 model: '',
451 platformVersion: '10.0.0',
452 uaFullVersion: '{version}.0.0.0',
453 fullVersionList: [
454 {{ brand: 'Google Chrome', version: '{version}.0.0.0' }},
455 {{ brand: 'Chromium', version: '{version}.0.0.0' }},
456 {{ brand: 'Not=A?Brand', version: '99.0.0.0' }},
457 ],
458 }}),
459 toJSON: () => ({{
460 brands: [
461 {{ brand: 'Google Chrome', version: '{version}' }},
462 {{ brand: 'Chromium', version: '{version}' }},
463 {{ brand: 'Not=A?Brand', version: '99' }},
464 ],
465 mobile: {mobile},
466 platform: '{platform}',
467 }}),
468 }};
469 try {{
470 Object.defineProperty(navigator, 'userAgentData', {{
471 get: () => uaData,
472 enumerable: true,
473 configurable: false,
474 }});
475 }} catch (_) {{}}
476 }})();"
477 )
478 }
479
480 fn webgl_spoof_script(&self) -> String {
481 let nav = &self.navigator;
482
483 format!(
484 r" // --- WebGL fingerprint spoofing ---
485 (function() {{
486 const GL_VENDOR = 0x1F00;
487 const GL_RENDERER = 0x1F01;
488
489 const spoofCtx = (ctx) => {{
490 if (!ctx) return;
491 const origGetParam = ctx.getParameter.bind(ctx);
492 ctx.getParameter = (param) => {{
493 if (param === GL_VENDOR) return {webgl_vendor:?};
494 if (param === GL_RENDERER) return {webgl_renderer:?};
495 return origGetParam(param);
496 }};
497 }};
498
499 // Wrap HTMLCanvasElement.prototype.getContext
500 const origGetContext = HTMLCanvasElement.prototype.getContext;
501 HTMLCanvasElement.prototype.getContext = function(type, ...args) {{
502 const ctx = origGetContext.call(this, type, ...args);
503 if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') {{
504 spoofCtx(ctx);
505 }}
506 return ctx;
507 }};
508 }})();",
509 webgl_vendor = nav.webgl_vendor,
510 webgl_renderer = nav.webgl_renderer,
511 )
512 }
513}
514
515#[allow(clippy::too_many_lines)]
547pub async fn apply_stealth_to_page(
548 page: &chromiumoxide::Page,
549 config: &crate::config::BrowserConfig,
550) -> crate::error::Result<()> {
551 use crate::cdp_protection::CdpProtection;
552 use crate::config::StealthLevel;
553 use crate::error::BrowserError;
554 use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
555
556 async fn inject_one(
558 page: &chromiumoxide::Page,
559 op: &'static str,
560 source: String,
561 ) -> crate::error::Result<()> {
562 use crate::error::BrowserError;
563 page.evaluate_on_new_document(AddScriptToEvaluateOnNewDocumentParams {
564 source,
565 world_name: None,
566 include_command_line_api: None,
567 run_immediately: None,
568 })
569 .await
570 .map_err(|e| BrowserError::CdpError {
571 operation: op.to_string(),
572 message: e.to_string(),
573 })?;
574 Ok(())
575 }
576
577 if config.stealth_level == StealthLevel::None {
578 return Ok(());
579 }
580
581 let effective_profile = (config.stealth_level == StealthLevel::Advanced).then(|| {
584 config
585 .fingerprint_profile
586 .clone()
587 .unwrap_or_else(|| fallback_profile_for_config(config))
588 });
589
590 #[cfg(feature = "stealth")]
592 {
593 let hardening_script = crate::cdp_hardening::cdp_hardening_script(&config.cdp_hardening);
594 if !hardening_script.is_empty() {
595 inject_one(
596 page,
597 "AddScriptToEvaluateOnNewDocument(cdp-hardening)",
598 hardening_script,
599 )
600 .await?;
601 }
602 }
603
604 let cdp_script =
606 CdpProtection::new(config.cdp_fix_mode, config.source_url.clone()).build_injection_script();
607 if !cdp_script.is_empty() {
608 inject_one(page, "AddScriptToEvaluateOnNewDocument(cdp)", cdp_script).await?;
609 }
610
611 let (nav_profile, stealth_cfg) = match config.stealth_level {
612 StealthLevel::Basic => (NavigatorProfile::default(), StealthConfig::minimal()),
613 StealthLevel::Advanced => {
614 let profile = effective_profile
615 .as_ref()
616 .ok_or_else(|| BrowserError::ConfigError("missing advanced profile".to_string()))?;
617 (
618 navigator_profile_from_coherent_profile(profile),
619 StealthConfig::paranoid(),
620 )
621 }
622 StealthLevel::None => unreachable!(),
623 };
624 let nav_script = StealthProfile::new(stealth_cfg, nav_profile).injection_script();
625 if !nav_script.is_empty() {
626 inject_one(
627 page,
628 "AddScriptToEvaluateOnNewDocument(navigator)",
629 nav_script,
630 )
631 .await?;
632 }
633
634 if config.stealth_level == StealthLevel::Advanced {
636 let profile = effective_profile
637 .as_ref()
638 .ok_or_else(|| BrowserError::ConfigError("missing advanced profile".to_string()))?;
639 let fp = fingerprint_from_coherent_profile(profile);
640 let noise_seed = profile.noise_seed;
642 let noise_engine = crate::noise::NoiseEngine::new(noise_seed);
643 let noise_seed = noise_engine.seed();
644 let fp_script = crate::fingerprint::inject_fingerprint(&fp);
645 inject_one(
646 page,
647 "AddScriptToEvaluateOnNewDocument(fingerprint)",
648 fp_script,
649 )
650 .await?;
651
652 let webrtc_script = config.webrtc.injection_script();
653 if !webrtc_script.is_empty() {
654 inject_one(
655 page,
656 "AddScriptToEvaluateOnNewDocument(webrtc)",
657 webrtc_script,
658 )
659 .await?;
660 }
661
662 if config.noise.canvas_enabled {
663 let canvas_script = crate::canvas_noise::canvas_noise_script(&noise_engine);
664 inject_one(
665 page,
666 "AddScriptToEvaluateOnNewDocument(canvas-noise)",
667 canvas_script,
668 )
669 .await?;
670 }
671
672 if config.noise.webgl_enabled {
674 let webgl_script =
675 crate::webgl_noise::webgl_noise_script(&profile.webgl, &noise_engine);
676 inject_one(
677 page,
678 "AddScriptToEvaluateOnNewDocument(webgl-noise)",
679 webgl_script,
680 )
681 .await?;
682 }
683
684 if config.noise.audio_enabled {
685 let audio_script = crate::audio_noise::audio_noise_script(&noise_engine);
686 inject_one(
687 page,
688 "AddScriptToEvaluateOnNewDocument(audio-noise)",
689 audio_script,
690 )
691 .await?;
692 }
693
694 if config.noise.rects_enabled {
695 let rects_script = crate::rects_noise::rects_noise_script(&noise_engine);
696 inject_one(
697 page,
698 "AddScriptToEvaluateOnNewDocument(rects-noise)",
699 rects_script,
700 )
701 .await?;
702 }
703
704 let nav_script = crate::navigator_coherence::navigator_coherence_script(profile);
707 inject_one(
708 page,
709 "AddScriptToEvaluateOnNewDocument(navigator-coherence)",
710 nav_script,
711 )
712 .await?;
713
714 {
716 let timing_cfg = crate::timing_noise::TimingNoiseConfig {
717 enabled: true,
718 jitter_ms: 0.3,
719 seed: noise_seed,
720 };
721 let timing_script = crate::timing_noise::timing_noise_script(&timing_cfg);
722 inject_one(
723 page,
724 "AddScriptToEvaluateOnNewDocument(timing-noise)",
725 timing_script,
726 )
727 .await?;
728 }
729
730 {
732 let peripheral_cfg =
733 crate::peripheral_stealth::PeripheralStealthConfig::default_with_seed(noise_seed);
734 let peripheral_script =
735 crate::peripheral_stealth::peripheral_stealth_script_with_profile(
736 &peripheral_cfg,
737 Some(profile),
738 );
739 if !peripheral_script.is_empty() {
740 inject_one(
741 page,
742 "AddScriptToEvaluateOnNewDocument(peripheral-stealth)",
743 peripheral_script,
744 )
745 .await?;
746 }
747 }
748 }
749
750 Ok(())
751}
752
753fn navigator_profile_from_coherent_profile(
754 profile: &crate::profile::FingerprintProfile,
755) -> NavigatorProfile {
756 NavigatorProfile {
757 user_agent: profile.browser.user_agent.clone(),
758 platform: profile.platform.platform_string.clone(),
759 vendor: "Google Inc.".to_string(),
760 hardware_concurrency: u8::try_from(profile.hardware.cores).unwrap_or(8),
761 device_memory: u8::try_from(profile.hardware.memory_gb).unwrap_or(8),
762 max_touch_points: profile.platform.max_touch_points,
763 webgl_vendor: profile.webgl.vendor.clone(),
764 webgl_renderer: profile.webgl.renderer.clone(),
765 }
766}
767
768fn fingerprint_from_coherent_profile(
769 profile: &crate::profile::FingerprintProfile,
770) -> crate::fingerprint::Fingerprint {
771 crate::fingerprint::Fingerprint {
772 user_agent: profile.browser.user_agent.clone(),
773 screen_resolution: (profile.screen.width, profile.screen.height),
774 timezone: fingerprint_timezone_from_coherent_profile(profile),
778 language: fingerprint_language_from_coherent_profile(profile),
779 platform: profile.platform.platform_string.clone(),
780 hardware_concurrency: profile.hardware.cores,
781 device_memory: profile.hardware.memory_gb,
782 webgl_vendor: Some(profile.webgl.vendor.clone()),
783 webgl_renderer: Some(profile.webgl.renderer.clone()),
784 canvas_noise: true,
785 fonts: Vec::new(),
786 }
787}
788
789fn fallback_profile_for_config(
790 config: &crate::config::BrowserConfig,
791) -> crate::profile::FingerprintProfile {
792 let seed = config.noise.seed.map_or_else(
793 || {
794 std::ptr::from_ref(config) as usize as u64
797 },
798 crate::noise::NoiseSeed::as_u64,
799 );
800
801 match seed % 100 {
803 0..=64 => crate::profile::FingerprintProfile::windows_chrome_136_rtx3060(),
804 65..=84 => crate::profile::FingerprintProfile::macos_chrome_136_m1(),
805 85..=89 => crate::profile::FingerprintProfile::linux_chrome_136_intel(),
806 _ => crate::profile::FingerprintProfile::android_chrome_136_pixel(),
807 }
808}
809
810fn deterministic_profile_choice<'a>(seed: u64, salt: &str, choices: &'a [&'a str]) -> &'a str {
811 let Some(default_choice) = choices.first().copied() else {
812 return "";
813 };
814
815 let mut hash = seed ^ 0xcbf2_9ce4_8422_2325_u64;
816 for byte in salt.as_bytes() {
817 hash ^= u64::from(*byte);
818 hash = hash.wrapping_mul(0x100_0000_01b3);
819 }
820
821 let Ok(len_u64) = u64::try_from(choices.len()) else {
822 return default_choice;
823 };
824 if len_u64 == 0 {
825 return default_choice;
826 }
827
828 let idx_u64 = hash % len_u64;
829 let Ok(idx) = usize::try_from(idx_u64) else {
830 return default_choice;
831 };
832
833 choices.get(idx).copied().unwrap_or(default_choice)
834}
835
836fn fingerprint_timezone_from_coherent_profile(
837 profile: &crate::profile::FingerprintProfile,
838) -> String {
839 const TZ_WINDOWS: &[&str] = &[
840 "America/New_York",
841 "America/Chicago",
842 "America/Denver",
843 "America/Los_Angeles",
844 "America/Toronto",
845 ];
846 const TZ_MAC: &[&str] = &[
847 "America/Los_Angeles",
848 "America/New_York",
849 "Europe/London",
850 "Europe/Paris",
851 ];
852 const TZ_LINUX: &[&str] = &[
853 "Europe/Berlin",
854 "Europe/Amsterdam",
855 "Europe/London",
856 "America/New_York",
857 ];
858 const TZ_ANDROID: &[&str] = &[
859 "Asia/Tokyo",
860 "Asia/Seoul",
861 "Asia/Singapore",
862 "Australia/Sydney",
863 "America/Los_Angeles",
864 ];
865
866 let choices = match profile.platform.os {
867 crate::profile::Os::Windows => TZ_WINDOWS,
868 crate::profile::Os::MacOs => TZ_MAC,
869 crate::profile::Os::Linux => TZ_LINUX,
870 crate::profile::Os::Android | crate::profile::Os::Ios => TZ_ANDROID,
871 };
872
873 deterministic_profile_choice(profile.noise_seed.as_u64(), "timezone", choices).to_string()
874}
875
876fn fingerprint_language_from_coherent_profile(
877 profile: &crate::profile::FingerprintProfile,
878) -> String {
879 const LANGUAGES: &[&str] = &[
880 "en-US", "en-GB", "fr-FR", "de-DE", "es-ES", "it-IT", "nl-NL", "pt-BR", "sv-SE",
881 ];
882
883 let keyboard = profile.platform.keyboard_layout.trim();
884 let normalized = match keyboard {
885 "en" | "en-US" | "en_US" | "us" | "US" => Some("en-US"),
886 "en-GB" | "en_GB" | "uk" | "UK" => Some("en-GB"),
887 "fr" | "fr-FR" | "fr_FR" => Some("fr-FR"),
888 "de" | "de-DE" | "de_DE" => Some("de-DE"),
889 "es" | "es-ES" | "es_ES" => Some("es-ES"),
890 "it" | "it-IT" | "it_IT" => Some("it-IT"),
891 "nl" | "nl-NL" | "nl_NL" => Some("nl-NL"),
892 "pt" | "pt-BR" | "pt_BR" => Some("pt-BR"),
893 "sv" | "sv-SE" | "sv_SE" => Some("sv-SE"),
894 _ => None,
895 };
896 if let Some(lang) = normalized {
897 return lang.to_string();
898 }
899
900 deterministic_profile_choice(profile.noise_seed.as_u64(), "language", LANGUAGES).to_string()
901}
902
903#[cfg(test)]
906mod tests {
907 use super::*;
908
909 #[test]
910 fn disabled_config_produces_empty_script() {
911 let p = StealthProfile::new(StealthConfig::disabled(), NavigatorProfile::default());
912 assert_eq!(p.injection_script(), "");
913 }
914
915 #[test]
916 fn navigator_signature_is_stable_and_changes_with_profile() {
917 let a = NavigatorProfile::windows_chrome().signature();
918 let b = NavigatorProfile::windows_chrome().signature();
919 assert_eq!(a, b, "identical profiles must produce equal signatures");
920 let c = NavigatorProfile::mac_chrome().signature();
921 assert_ne!(a, c, "different profiles must produce different signatures");
922 assert!(a.starts_with("fnv64:"));
923 }
924
925 #[test]
926 fn navigator_script_contains_platform() {
927 let profile = NavigatorProfile::windows_chrome();
928 let p = StealthProfile::new(StealthConfig::minimal(), profile);
929 let script = p.injection_script();
930 assert!(script.contains("Win32"), "platform must be in script");
931 assert!(
932 script.contains("'webdriver'"),
933 "webdriver removal must be present"
934 );
935 }
936
937 #[test]
938 fn navigator_script_contains_user_agent() {
939 let p = StealthProfile::new(StealthConfig::minimal(), NavigatorProfile::mac_chrome());
940 let script = p.injection_script();
941 assert!(script.contains("Mac OS X"));
942 assert!(script.contains("MacIntel"));
943 }
944
945 #[test]
946 fn webgl_script_contains_vendor_renderer() {
947 let p = StealthProfile::new(
948 StealthConfig {
949 spoof_navigator: false,
950 randomize_webgl: true,
951 ..StealthConfig::disabled()
952 },
953 NavigatorProfile::windows_chrome(),
954 );
955 let script = p.injection_script();
956 assert!(
957 script.contains("NVIDIA"),
958 "WebGL vendor must appear in script"
959 );
960 assert!(
961 script.contains("getParameter"),
962 "WebGL method must be overridden"
963 );
964 }
965
966 #[test]
967 fn full_profile_wraps_in_iife() {
968 let p = StealthProfile::new(StealthConfig::default(), NavigatorProfile::default());
969 let script = p.injection_script();
970 assert!(script.starts_with("(function()"), "script must be an IIFE");
971 assert!(script.ends_with("})();"));
972 }
973
974 #[test]
975 fn navigator_profile_linux_has_correct_platform() {
976 assert_eq!(NavigatorProfile::linux_chrome().platform, "Linux x86_64");
977 }
978
979 #[test]
980 fn stealth_config_paranoid_equals_default() {
981 let a = StealthConfig::paranoid();
982 let b = StealthConfig::default();
983 assert_eq!(a.spoof_navigator, b.spoof_navigator);
984 assert_eq!(a.randomize_webgl, b.randomize_webgl);
985 assert_eq!(a.randomize_canvas, b.randomize_canvas);
986 assert_eq!(a.human_behavior, b.human_behavior);
987 assert_eq!(a.protect_cdp, b.protect_cdp);
988 }
989
990 #[test]
991 fn hardware_concurrency_reasonable() {
992 let p = NavigatorProfile::windows_chrome();
993 assert!(p.hardware_concurrency >= 2);
994 assert!(p.hardware_concurrency <= 64);
995 }
996
997 #[test]
1000 fn none_level_is_not_active() {
1001 use crate::config::StealthLevel;
1002 assert!(!StealthLevel::None.is_active());
1003 }
1004
1005 #[test]
1006 fn basic_level_cdp_script_removes_webdriver() {
1007 use crate::cdp_protection::{CdpFixMode, CdpProtection};
1008 let script = CdpProtection::new(CdpFixMode::AddBinding, None).build_injection_script();
1009 assert!(
1010 script.contains("webdriver"),
1011 "CDP protection script should remove navigator.webdriver"
1012 );
1013 }
1014
1015 #[test]
1016 fn basic_level_minimal_config_injects_navigator() {
1017 let config = StealthConfig::minimal();
1018 let profile = NavigatorProfile::default();
1019 let script = StealthProfile::new(config, profile).injection_script();
1020 assert!(
1021 !script.is_empty(),
1022 "Basic stealth should produce a navigator script"
1023 );
1024 }
1025
1026 #[test]
1027 fn advanced_level_paranoid_config_includes_webgl() {
1028 let config = StealthConfig::paranoid();
1029 let profile = NavigatorProfile::windows_chrome();
1030 let script = StealthProfile::new(config, profile).injection_script();
1031 assert!(
1032 script.contains("webgl") && script.contains("getParameter"),
1033 "Advanced stealth should spoof WebGL via getParameter patching"
1034 );
1035 }
1036
1037 #[test]
1038 fn advanced_level_fingerprint_script_non_empty() {
1039 use crate::fingerprint::{Fingerprint, inject_fingerprint};
1040 let fp = Fingerprint::random();
1041 let script = inject_fingerprint(&fp);
1042 assert!(
1043 !script.is_empty(),
1044 "Fingerprint injection script must not be empty"
1045 );
1046 }
1047
1048 #[test]
1049 fn stealth_level_ordering() {
1050 use crate::config::StealthLevel;
1051 assert!(!StealthLevel::None.is_active());
1052 assert!(StealthLevel::Basic.is_active());
1053 assert!(StealthLevel::Advanced.is_active());
1054 }
1055
1056 #[test]
1057 fn navigator_profile_basic_uses_default() {
1058 let profile = NavigatorProfile::default();
1060 assert_eq!(profile.platform, "MacIntel");
1061 }
1062
1063 #[test]
1064 fn navigator_profile_advanced_uses_windows() {
1065 let profile = NavigatorProfile::windows_chrome();
1066 assert_eq!(profile.platform, "Win32");
1067 }
1068}