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