Skip to main content

stygian_browser/
config.rs

1//! Browser configuration and options
2//!
3//! All configuration can be overridden via environment variables at runtime.
4//! See individual fields for the corresponding `STYGIAN_*` variable names.
5//!
6//! ## Configuration priority
7//!
8//! Programmatic (builder) > environment variables > JSON file > compiled-in defaults.
9//!
10//! Use [`BrowserConfig::from_json_file`] or [`BrowserConfig::from_json_str`] to
11//! load a base configuration from disk, then override individual settings via
12//! the builder or environment variables.
13
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16use std::sync::Arc;
17use std::time::Duration;
18
19use crate::cdp_protection::CdpFixMode;
20
21#[cfg(feature = "stealth")]
22use crate::noise::NoiseConfig;
23#[cfg(feature = "stealth")]
24use crate::webrtc::WebRtcConfig;
25
26// ─── HeadlessMode ───────────────────────────────────────────────────────────────
27
28/// Controls which headless mode Chrome is launched in.
29///
30/// The *new* headless mode (`--headless=new`, available since Chromium 112)
31/// shares the same rendering pipeline as a headed Chrome window and is
32/// harder to fingerprint-detect. It is the default.
33///
34/// Fall back to [`Legacy`][HeadlessMode::Legacy] only when targeting very old
35/// Chromium builds that do not support `--headless=new`.
36///
37/// Env: `STYGIAN_HEADLESS_MODE` (`new`/`legacy`, default: `new`)
38///
39/// # Example
40///
41/// ```
42/// use stygian_browser::BrowserConfig;
43/// use stygian_browser::config::HeadlessMode;
44/// let cfg = BrowserConfig::builder()
45///     .headless(true)
46///     .headless_mode(HeadlessMode::New)
47///     .build();
48/// assert_eq!(cfg.headless_mode, HeadlessMode::New);
49/// ```
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
51#[serde(rename_all = "lowercase")]
52pub enum HeadlessMode {
53    /// `--headless=new` — shares Chrome's headed rendering pipeline.
54    /// Default. Requires Chromium 112+.
55    #[default]
56    New,
57    /// Classic `--headless` flag. Use only for Chromium < 112.
58    Legacy,
59}
60
61impl HeadlessMode {
62    /// Read from `STYGIAN_HEADLESS_MODE` env var (`new`/`legacy`).
63    #[must_use]
64    pub fn from_env() -> Self {
65        match std::env::var("STYGIAN_HEADLESS_MODE")
66            .unwrap_or_default()
67            .to_lowercase()
68            .as_str()
69        {
70            "legacy" => Self::Legacy,
71            _ => Self::New,
72        }
73    }
74}
75
76// ─── StealthLevel ─────────────────────────────────────────────────────────────
77
78/// Anti-detection intensity level.
79///
80/// Higher levels apply more fingerprint spoofing and behavioral mimicry at the
81/// cost of additional CPU/memory overhead.
82///
83/// # Example
84///
85/// ```
86/// use stygian_browser::config::StealthLevel;
87/// let level = StealthLevel::Advanced;
88/// assert!(level.is_active());
89/// ```
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
91#[serde(rename_all = "lowercase")]
92pub enum StealthLevel {
93    /// No anti-detection applied. Useful for trusted, internal targets.
94    None,
95    /// Core protections only: `navigator.webdriver` removal and CDP leak fix.
96    Basic,
97    /// Full suite: fingerprint injection, human behavior, WebRTC spoofing.
98    #[default]
99    Advanced,
100}
101
102impl StealthLevel {
103    /// Returns `true` for any level other than [`StealthLevel::None`].
104    #[must_use]
105    pub fn is_active(self) -> bool {
106        self != Self::None
107    }
108
109    /// Parse `source_url` from `STYGIAN_SOURCE_URL` (`0` disables).
110    #[must_use]
111    pub fn from_env() -> Self {
112        match std::env::var("STYGIAN_STEALTH_LEVEL")
113            .unwrap_or_default()
114            .to_lowercase()
115            .as_str()
116        {
117            "none" => Self::None,
118            "basic" => Self::Basic,
119            _ => Self::Advanced,
120        }
121    }
122}
123
124// ─── PoolConfig ───────────────────────────────────────────────────────────────
125
126/// Browser pool sizing and lifecycle settings.
127///
128/// # Example
129///
130/// ```
131/// use stygian_browser::config::PoolConfig;
132/// let cfg = PoolConfig::default();
133/// assert_eq!(cfg.min_size, 2);
134/// assert_eq!(cfg.max_size, 10);
135/// ```
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PoolConfig {
138    /// Minimum warm instances kept ready at all times.
139    ///
140    /// Env: `STYGIAN_POOL_MIN` (default: `2`)
141    pub min_size: usize,
142
143    /// Maximum concurrent browser instances.
144    ///
145    /// Env: `STYGIAN_POOL_MAX` (default: `10`)
146    pub max_size: usize,
147
148    /// How long an idle browser is kept before eviction.
149    ///
150    /// Env: `STYGIAN_POOL_IDLE_SECS` (default: `300`)
151    #[serde(with = "duration_secs")]
152    pub idle_timeout: Duration,
153
154    /// Maximum time to wait for a pool slot before returning
155    /// [`PoolExhausted`][crate::error::BrowserError::PoolExhausted].
156    ///
157    /// Env: `STYGIAN_POOL_ACQUIRE_SECS` (default: `5`)
158    #[serde(with = "duration_secs")]
159    pub acquire_timeout: Duration,
160}
161
162impl Default for PoolConfig {
163    fn default() -> Self {
164        Self {
165            min_size: env_usize("STYGIAN_POOL_MIN", 2),
166            max_size: env_usize("STYGIAN_POOL_MAX", 10),
167            idle_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_IDLE_SECS", 300)),
168            acquire_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_ACQUIRE_SECS", 5)),
169        }
170    }
171}
172
173// ─── BrowserConfig ────────────────────────────────────────────────────────────
174
175/// Top-level configuration for a browser session.
176///
177/// # Example
178///
179/// ```
180/// use stygian_browser::BrowserConfig;
181///
182/// let config = BrowserConfig::builder()
183///     .headless(true)
184///     .window_size(1920, 1080)
185///     .build();
186///
187/// assert!(config.headless);
188/// ```
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct BrowserConfig {
191    /// Path to the Chrome/Chromium executable.
192    ///
193    /// Env: `STYGIAN_CHROME_PATH`
194    pub chrome_path: Option<PathBuf>,
195
196    /// Extra Chrome launch arguments appended after the defaults.
197    pub args: Vec<String>,
198
199    /// Run in headless mode (no visible window).
200    ///
201    /// Env: `STYGIAN_HEADLESS` (`true`/`false`, default: `true`)
202    pub headless: bool,
203
204    /// Persistent user profile directory. `None` = temporary profile.
205    pub user_data_dir: Option<PathBuf>,
206
207    /// Which headless mode to use when `headless` is `true`.
208    ///
209    /// Defaults to [`HeadlessMode::New`] (`--headless=new`).
210    ///
211    /// Env: `STYGIAN_HEADLESS_MODE` (`new`/`legacy`)
212    pub headless_mode: HeadlessMode,
213
214    /// Browser window size in pixels (width, height).
215    pub window_size: Option<(u32, u32)>,
216
217    /// Attach `DevTools` on launch (useful for debugging, disable in production).
218    pub devtools: bool,
219
220    /// HTTP/SOCKS proxy URL, e.g. `http://user:pass@host:port`.
221    pub proxy: Option<String>,
222
223    /// Comma-separated list of hosts that bypass the proxy.
224    ///
225    /// Env: `STYGIAN_PROXY_BYPASS` (e.g. `"<local>,localhost,127.0.0.1"`)
226    pub proxy_bypass_list: Option<String>,
227
228    /// WebRTC IP-leak prevention and geolocation consistency settings.
229    ///
230    /// Only active when the `stealth` feature is enabled.
231    #[cfg(feature = "stealth")]
232    pub webrtc: WebRtcConfig,
233
234    /// Deterministic noise configuration for fingerprint perturbation.
235    ///
236    /// Only active when the `stealth` feature is enabled.
237    #[cfg(feature = "stealth")]
238    pub noise: NoiseConfig,
239
240    /// CDP leak hardening configuration.
241    ///
242    /// Controls removal of Playwright/Puppeteer binding remnants, `Error.stack`
243    /// sanitization, and `console.debug` protection. Only active when the
244    /// `stealth` feature is enabled.
245    #[cfg(feature = "stealth")]
246    pub cdp_hardening: crate::cdp_hardening::CdpHardeningConfig,
247
248    /// Unified fingerprint profile for coherent identity injection.
249    ///
250    /// When set, navigator properties and other identity signals are overridden
251    /// to form a self-consistent browser/device identity. Only active when the
252    /// `stealth` feature is enabled.
253    #[cfg(feature = "stealth")]
254    pub fingerprint_profile: Option<crate::profile::FingerprintProfile>,
255
256    /// Anti-detection intensity level.
257    pub stealth_level: StealthLevel,
258
259    /// Disable Chromium's built-in renderer sandbox (`--no-sandbox`).
260    ///
261    /// Chromium's sandbox requires user namespaces, which are unavailable inside
262    /// most container runtimes. When running in Docker or similar, set this to
263    /// `true` (or set `STYGIAN_DISABLE_SANDBOX=true`) and rely on the
264    /// container's own isolation instead.
265    ///
266    /// **Never set this on a bare-metal host without an alternative isolation
267    /// boundary.** Doing so removes a meaningful security layer.
268    ///
269    /// Env: `STYGIAN_DISABLE_SANDBOX` (`true`/`false`, default: auto-detect)
270    pub disable_sandbox: bool,
271
272    /// CDP Runtime.enable leak-mitigation mode.
273    ///
274    /// Env: `STYGIAN_CDP_FIX_MODE` (`add_binding`/`isolated_world`/`enable_disable`/`none`)
275    pub cdp_fix_mode: CdpFixMode,
276
277    /// Source URL injected into `Function.prototype.toString` patches, or
278    /// `None` to use the default (`"app.js"`).
279    ///
280    /// Set to `"0"` (as a string) to disable sourceURL patching entirely.
281    ///
282    /// Env: `STYGIAN_SOURCE_URL`
283    pub source_url: Option<String>,
284
285    /// Browser pool settings.
286    pub pool: PoolConfig,
287
288    /// Browser launch timeout.
289    ///
290    /// Env: `STYGIAN_LAUNCH_TIMEOUT_SECS` (default: `10`)
291    #[serde(with = "duration_secs")]
292    pub launch_timeout: Duration,
293
294    /// Per-operation CDP timeout.
295    ///
296    /// Env: `STYGIAN_CDP_TIMEOUT_SECS` (default: `30`)
297    #[serde(with = "duration_secs")]
298    pub cdp_timeout: Duration,
299
300    /// Optional proxy source for dynamic per-context proxy rotation.
301    ///
302    /// When set, each newly launched browser instance acquires its proxy URL
303    /// from this source via [`crate::proxy::ProxySource::bind_proxy`], enabling
304    /// circuit-breaker-backed rotation.  Takes precedence over the static
305    /// [`proxy`](BrowserConfig::proxy) field for any instance launched while
306    /// this is set.
307    ///
308    /// Not serialized — set programmatically via the builder.
309    #[serde(skip)]
310    pub proxy_source: Option<Arc<dyn crate::proxy::ProxySource>>,
311}
312
313impl Default for BrowserConfig {
314    fn default() -> Self {
315        Self {
316            chrome_path: std::env::var("STYGIAN_CHROME_PATH").ok().map(PathBuf::from),
317            args: vec![],
318            headless: env_bool("STYGIAN_HEADLESS", true),
319            user_data_dir: None,
320            headless_mode: HeadlessMode::from_env(),
321            window_size: Some((1920, 1080)),
322            devtools: false,
323            proxy: std::env::var("STYGIAN_PROXY").ok(),
324            proxy_bypass_list: std::env::var("STYGIAN_PROXY_BYPASS").ok(),
325            #[cfg(feature = "stealth")]
326            webrtc: WebRtcConfig::default(),
327            #[cfg(feature = "stealth")]
328            noise: NoiseConfig::default(),
329            #[cfg(feature = "stealth")]
330            cdp_hardening: crate::cdp_hardening::CdpHardeningConfig::default(),
331            #[cfg(feature = "stealth")]
332            fingerprint_profile: None,
333            disable_sandbox: env_bool("STYGIAN_DISABLE_SANDBOX", is_containerized()),
334            stealth_level: StealthLevel::from_env(),
335            cdp_fix_mode: CdpFixMode::from_env(),
336            source_url: std::env::var("STYGIAN_SOURCE_URL").ok(),
337            pool: PoolConfig::default(),
338            launch_timeout: Duration::from_secs(env_u64("STYGIAN_LAUNCH_TIMEOUT_SECS", 10)),
339            cdp_timeout: Duration::from_secs(env_u64("STYGIAN_CDP_TIMEOUT_SECS", 30)),
340            proxy_source: None,
341        }
342    }
343}
344
345impl BrowserConfig {
346    /// Create a configuration builder with defaults pre-populated.
347    #[must_use]
348    pub fn builder() -> BrowserConfigBuilder {
349        BrowserConfigBuilder {
350            config: Self::default(),
351        }
352    }
353
354    /// Build an opinionated high-stealth profile for direct traffic (no proxy).
355    ///
356    /// This profile maximizes anti-bot resistance while avoiding proxy-specific
357    /// assumptions:
358    ///
359    /// 1. `StealthLevel::Advanced`
360    /// 2. `HeadlessMode::New`
361    /// 3. `CdpFixMode::AddBinding`
362    /// 4. `WebRtcPolicy::BlockAll` (strongest non-proxy IP leak prevention)
363    /// 5. Default deterministic noise layers enabled
364    /// 6. Weighted coherent fingerprint profile
365    ///
366    /// # Example
367    ///
368    /// ```
369    /// use stygian_browser::BrowserConfig;
370    /// use stygian_browser::config::{HeadlessMode, StealthLevel};
371    ///
372    /// let cfg = BrowserConfig::stealth_profile_without_proxy();
373    /// assert_eq!(cfg.stealth_level, StealthLevel::Advanced);
374    /// assert_eq!(cfg.headless_mode, HeadlessMode::New);
375    /// ```
376    #[cfg(feature = "stealth")]
377    #[must_use]
378    pub fn stealth_profile_without_proxy() -> Self {
379        Self::builder()
380            .stealth_level(StealthLevel::Advanced)
381            .headless_mode(HeadlessMode::New)
382            .cdp_fix_mode(CdpFixMode::AddBinding)
383            .webrtc(crate::webrtc::WebRtcConfig {
384                policy: crate::webrtc::WebRtcPolicy::BlockAll,
385                ..Default::default()
386            })
387            .noise(crate::noise::NoiseConfig::default())
388            .fingerprint_profile(crate::profile::FingerprintProfile::random_weighted())
389            .build()
390    }
391
392    /// Build an opinionated high-stealth profile for proxied traffic.
393    ///
394    /// This profile keeps the same anti-bot posture as
395    /// [`stealth_profile_without_proxy`](Self::stealth_profile_without_proxy)
396    /// and configures proxy-aware WebRTC handling.
397    ///
398    /// 1. `StealthLevel::Advanced`
399    /// 2. `HeadlessMode::New`
400    /// 3. `CdpFixMode::AddBinding`
401    /// 4. `WebRtcPolicy::DisableNonProxied` (prevents direct UDP leaks)
402    /// 5. Default deterministic noise layers enabled
403    /// 6. Weighted coherent fingerprint profile
404    ///
405    /// # Example
406    ///
407    /// ```
408    /// use stygian_browser::BrowserConfig;
409    ///
410    /// let cfg = BrowserConfig::stealth_profile_with_proxy("http://127.0.0.1:8080");
411    /// assert!(cfg.proxy.is_some());
412    /// ```
413    #[cfg(feature = "stealth")]
414    #[must_use]
415    pub fn stealth_profile_with_proxy(proxy_url: impl Into<String>) -> Self {
416        Self::builder()
417            .proxy(proxy_url.into())
418            .stealth_level(StealthLevel::Advanced)
419            .headless_mode(HeadlessMode::New)
420            .cdp_fix_mode(CdpFixMode::AddBinding)
421            .webrtc(crate::webrtc::WebRtcConfig {
422                policy: crate::webrtc::WebRtcPolicy::DisableNonProxied,
423                ..Default::default()
424            })
425            .noise(crate::noise::NoiseConfig::default())
426            .fingerprint_profile(crate::profile::FingerprintProfile::random_weighted())
427            .build()
428    }
429
430    /// Collect the effective Chrome launch arguments.
431    ///
432    /// Returns the anti-detection baseline args merged with any user-supplied
433    /// extras from [`BrowserConfig::args`].
434    #[must_use]
435    pub fn effective_args(&self) -> Vec<String> {
436        let mut args = vec![
437            "--disable-blink-features=AutomationControlled".to_string(),
438            "--disable-dev-shm-usage".to_string(),
439            "--disable-infobars".to_string(),
440            "--disable-background-timer-throttling".to_string(),
441            "--disable-backgrounding-occluded-windows".to_string(),
442            "--disable-renderer-backgrounding".to_string(),
443        ];
444
445        if self.disable_sandbox {
446            args.push("--no-sandbox".to_string());
447        }
448
449        if let Some(proxy) = &self.proxy {
450            args.push(format!("--proxy-server={proxy}"));
451        }
452
453        if let Some(bypass) = &self.proxy_bypass_list {
454            args.push(format!("--proxy-bypass-list={bypass}"));
455        }
456
457        #[cfg(feature = "stealth")]
458        args.extend(self.webrtc.chrome_args());
459
460        if let Some((w, h)) = self.window_size {
461            args.push(format!("--window-size={w},{h}"));
462        }
463
464        args.extend_from_slice(&self.args);
465        args
466    }
467
468    /// Validate the configuration, returning a list of human-readable errors.
469    ///
470    /// Returns `Ok(())` when valid, or `Err(errors)` with a non-empty list.
471    ///
472    /// # Example
473    ///
474    /// ```
475    /// use stygian_browser::BrowserConfig;
476    /// use stygian_browser::config::PoolConfig;
477    /// use std::time::Duration;
478    ///
479    /// let mut cfg = BrowserConfig::default();
480    /// cfg.pool.min_size = 0;
481    /// cfg.pool.max_size = 0; // invalid: max must be >= 1
482    /// let errors = cfg.validate().unwrap_err();
483    /// assert!(!errors.is_empty());
484    /// ```
485    ///
486    /// # Errors
487    ///
488    /// Returns `Err(Vec<String>)` listing every validation failure when
489    /// one or more invariant is violated (pool sizing, timeouts, argument
490    /// shapes, etc.). An empty `Err` vec is never produced — the `Ok(())`
491    /// path means every check passed.
492    pub fn validate(&self) -> Result<(), Vec<String>> {
493        let mut errors: Vec<String> = Vec::new();
494
495        if self.pool.min_size > self.pool.max_size {
496            errors.push(format!(
497                "pool.min_size ({}) must be <= pool.max_size ({})",
498                self.pool.min_size, self.pool.max_size
499            ));
500        }
501        if self.pool.max_size == 0 {
502            errors.push("pool.max_size must be >= 1".to_string());
503        }
504        if self.launch_timeout.is_zero() {
505            errors.push("launch_timeout must be positive".to_string());
506        }
507        if self.cdp_timeout.is_zero() {
508            errors.push("cdp_timeout must be positive".to_string());
509        }
510        if let Some(proxy) = &self.proxy
511            && !proxy.starts_with("http://")
512            && !proxy.starts_with("https://")
513            && !proxy.starts_with("socks4://")
514            && !proxy.starts_with("socks5://")
515        {
516            errors.push(format!(
517                "proxy URL must start with http://, https://, socks4:// or socks5://; got: {proxy}"
518            ));
519        }
520
521        if errors.is_empty() {
522            Ok(())
523        } else {
524            Err(errors)
525        }
526    }
527
528    /// Serialize this configuration to a JSON string.
529    ///
530    /// # Errors
531    ///
532    /// Returns a [`serde_json::Error`] if serialization fails (very rare).
533    ///
534    /// # Example
535    ///
536    /// ```
537    /// use stygian_browser::BrowserConfig;
538    /// let cfg = BrowserConfig::default();
539    /// let json = cfg.to_json().unwrap();
540    /// assert!(json.contains("headless"));
541    /// ```
542    pub fn to_json(&self) -> Result<String, serde_json::Error> {
543        serde_json::to_string_pretty(self)
544    }
545
546    /// Deserialize a [`BrowserConfig`] from a JSON string.
547    ///
548    /// Environment variable overrides will NOT be re-applied — the JSON values
549    /// are used verbatim.  Chain with builder methods to override individual
550    /// fields after loading.
551    ///
552    /// # Errors
553    ///
554    /// Returns a [`serde_json::Error`] if the input is invalid JSON or has
555    /// missing required fields.
556    ///
557    /// # Example
558    ///
559    /// ```
560    /// use stygian_browser::BrowserConfig;
561    /// let cfg = BrowserConfig::default();
562    /// let json = cfg.to_json().unwrap();
563    /// let back = BrowserConfig::from_json_str(&json).unwrap();
564    /// assert_eq!(back.headless, cfg.headless);
565    /// ```
566    pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
567        serde_json::from_str(s)
568    }
569
570    /// Load a [`BrowserConfig`] from a JSON file on disk.
571    ///
572    /// # Errors
573    ///
574    /// Returns a [`crate::error::BrowserError::ConfigError`] wrapping any I/O
575    /// or parse error.
576    ///
577    /// # Example
578    ///
579    /// ```no_run
580    /// use stygian_browser::BrowserConfig;
581    /// let cfg = BrowserConfig::from_json_file("/etc/stygian/config.json").unwrap();
582    /// ```
583    pub fn from_json_file(path: impl AsRef<std::path::Path>) -> crate::error::Result<Self> {
584        use crate::error::BrowserError;
585        let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
586            BrowserError::ConfigError(format!(
587                "cannot read config file {}: {e}",
588                path.as_ref().display()
589            ))
590        })?;
591        serde_json::from_str(&content).map_err(|e| {
592            BrowserError::ConfigError(format!(
593                "invalid JSON in config file {}: {e}",
594                path.as_ref().display()
595            ))
596        })
597    }
598}
599
600// ─── Builder ──────────────────────────────────────────────────────────────────
601
602/// Fluent builder for [`BrowserConfig`].
603pub struct BrowserConfigBuilder {
604    config: BrowserConfig,
605}
606
607impl BrowserConfigBuilder {
608    /// Set path to the Chrome executable.
609    #[must_use]
610    pub fn chrome_path(mut self, path: PathBuf) -> Self {
611        self.config.chrome_path = Some(path);
612        self
613    }
614
615    /// Set a custom user profile directory.
616    ///
617    /// When not set, each browser instance automatically uses a unique
618    /// temporary directory derived from its instance ID, preventing
619    /// `SingletonLock` races between concurrent pools or instances.
620    ///
621    /// # Example
622    ///
623    /// ```
624    /// use stygian_browser::BrowserConfig;
625    /// let cfg = BrowserConfig::builder()
626    ///     .user_data_dir("/tmp/my-profile")
627    ///     .build();
628    /// assert!(cfg.user_data_dir.is_some());
629    /// ```
630    #[must_use]
631    pub fn user_data_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
632        self.config.user_data_dir = Some(path.into());
633        self
634    }
635
636    /// Set headless mode.
637    #[must_use]
638    pub const fn headless(mut self, headless: bool) -> Self {
639        self.config.headless = headless;
640        self
641    }
642
643    /// Choose between `--headless=new` (default) and the legacy `--headless` flag.
644    ///
645    /// Only relevant when [`headless`][Self::headless] is `true`. Has no effect
646    /// in headed mode.
647    ///
648    /// # Example
649    ///
650    /// ```
651    /// use stygian_browser::BrowserConfig;
652    /// use stygian_browser::config::HeadlessMode;
653    /// let cfg = BrowserConfig::builder()
654    ///     .headless_mode(HeadlessMode::Legacy)
655    ///     .build();
656    /// assert_eq!(cfg.headless_mode, HeadlessMode::Legacy);
657    /// ```
658    #[must_use]
659    pub const fn headless_mode(mut self, mode: HeadlessMode) -> Self {
660        self.config.headless_mode = mode;
661        self
662    }
663
664    /// Set browser viewport / window size.
665    #[must_use]
666    pub const fn window_size(mut self, width: u32, height: u32) -> Self {
667        self.config.window_size = Some((width, height));
668        self
669    }
670
671    /// Enable or disable `DevTools` attachment.
672    #[must_use]
673    pub const fn devtools(mut self, enabled: bool) -> Self {
674        self.config.devtools = enabled;
675        self
676    }
677
678    /// Set proxy URL.
679    #[must_use]
680    pub fn proxy(mut self, proxy: String) -> Self {
681        self.config.proxy = Some(proxy);
682        self
683    }
684
685    /// Set a comma-separated proxy bypass list.
686    ///
687    /// # Example
688    /// ```
689    /// use stygian_browser::BrowserConfig;
690    /// let cfg = BrowserConfig::builder()
691    ///     .proxy("http://proxy:8080".to_string())
692    ///     .proxy_bypass_list("<local>,localhost".to_string())
693    ///     .build();
694    /// assert!(cfg.effective_args().iter().any(|a| a.contains("proxy-bypass")));
695    /// ```
696    #[must_use]
697    pub fn proxy_bypass_list(mut self, bypass: String) -> Self {
698        self.config.proxy_bypass_list = Some(bypass);
699        self
700    }
701
702    /// Set WebRTC IP-leak prevention config.
703    ///
704    /// # Example
705    /// ```
706    /// use stygian_browser::BrowserConfig;
707    /// use stygian_browser::webrtc::{WebRtcConfig, WebRtcPolicy};
708    /// let cfg = BrowserConfig::builder()
709    ///     .webrtc(WebRtcConfig { policy: WebRtcPolicy::BlockAll, ..Default::default() })
710    ///     .build();
711    /// assert!(cfg.effective_args().iter().any(|a| a.contains("disable_non_proxied")));
712    /// ```
713    #[cfg(feature = "stealth")]
714    #[must_use]
715    pub fn webrtc(mut self, webrtc: WebRtcConfig) -> Self {
716        self.config.webrtc = webrtc;
717        self
718    }
719
720    /// Set the fingerprint noise configuration.
721    ///
722    /// # Example
723    /// ```
724    /// use stygian_browser::BrowserConfig;
725    /// use stygian_browser::noise::{NoiseConfig, NoiseSeed};
726    /// let cfg = BrowserConfig::builder()
727    ///     .noise(NoiseConfig { seed: Some(NoiseSeed::from(42_u64)), ..Default::default() })
728    ///     .build();
729    /// assert_eq!(cfg.noise.seed.unwrap().as_u64(), 42);
730    /// ```
731    #[cfg(feature = "stealth")]
732    #[must_use]
733    pub const fn noise(mut self, config: NoiseConfig) -> Self {
734        self.config.noise = config;
735        self
736    }
737
738    /// Set the unified fingerprint profile for coherent identity injection.
739    ///
740    /// # Example
741    /// ```
742    /// use stygian_browser::BrowserConfig;
743    /// use stygian_browser::profile::FingerprintProfile;
744    /// let cfg = BrowserConfig::builder()
745    ///     .fingerprint_profile(FingerprintProfile::windows_chrome_136_rtx3060())
746    ///     .build();
747    /// assert!(cfg.fingerprint_profile.is_some());
748    /// ```
749    #[cfg(feature = "stealth")]
750    #[must_use]
751    pub fn fingerprint_profile(mut self, profile: crate::profile::FingerprintProfile) -> Self {
752        self.config.fingerprint_profile = Some(profile);
753        self
754    }
755
756    /// Set CDP leak hardening configuration.
757    ///
758    /// # Example
759    /// ```
760    /// use stygian_browser::BrowserConfig;
761    /// use stygian_browser::cdp_hardening::CdpHardeningConfig;
762    /// let cfg = BrowserConfig::builder()
763    ///     .cdp_hardening(CdpHardeningConfig { enabled: false, ..Default::default() })
764    ///     .build();
765    /// assert!(!cfg.cdp_hardening.enabled);
766    /// ```
767    #[cfg(feature = "stealth")]
768    #[must_use]
769    pub const fn cdp_hardening(mut self, config: crate::cdp_hardening::CdpHardeningConfig) -> Self {
770        self.config.cdp_hardening = config;
771        self
772    }
773
774    /// Append a custom Chrome argument.
775    #[must_use]
776    pub fn arg(mut self, arg: String) -> Self {
777        self.config.args.push(arg);
778        self
779    }
780
781    /// Add Chrome launch flags that constrain TLS to match a [`TlsProfile`].
782    ///
783    /// Appends version-constraint flags (e.g. `--ssl-version-max=tls1.2`)
784    /// to the extra args list. See [`chrome_tls_args`] for details on what
785    /// Chrome can and cannot control via flags.
786    ///
787    /// [`TlsProfile`]: crate::tls::TlsProfile
788    /// [`chrome_tls_args`]: crate::tls::chrome_tls_args
789    ///
790    /// # Example
791    ///
792    /// ```
793    /// use stygian_browser::BrowserConfig;
794    /// use stygian_browser::tls::CHROME_131;
795    ///
796    /// let cfg = BrowserConfig::builder()
797    ///     .tls_profile(&CHROME_131)
798    ///     .build();
799    /// // Chrome 131 supports both TLS 1.2 and 1.3 — no extra flags needed.
800    /// ```
801    #[cfg(feature = "stealth")]
802    #[must_use]
803    pub fn tls_profile(mut self, profile: &crate::tls::TlsProfile) -> Self {
804        self.config
805            .args
806            .extend(crate::tls::chrome_tls_args(profile));
807        self
808    }
809
810    /// Set the stealth level.
811    #[must_use]
812    pub const fn stealth_level(mut self, level: StealthLevel) -> Self {
813        self.config.stealth_level = level;
814        self
815    }
816
817    /// Explicitly control whether `--no-sandbox` is passed to Chrome.
818    ///
819    /// By default this is auto-detected: `true` inside containers, `false` on
820    /// bare metal. Override only when the auto-detection is wrong.
821    ///
822    /// # Example
823    ///
824    /// ```
825    /// use stygian_browser::BrowserConfig;
826    /// // Force sandbox on (bare-metal host)
827    /// let cfg = BrowserConfig::builder().disable_sandbox(false).build();
828    /// assert!(!cfg.effective_args().iter().any(|a| a == "--no-sandbox"));
829    /// ```
830    #[must_use]
831    pub const fn disable_sandbox(mut self, disable: bool) -> Self {
832        self.config.disable_sandbox = disable;
833        self
834    }
835
836    /// Set the CDP leak-mitigation mode.
837    ///
838    /// # Example
839    ///
840    /// ```
841    /// use stygian_browser::BrowserConfig;
842    /// use stygian_browser::cdp_protection::CdpFixMode;
843    /// let cfg = BrowserConfig::builder()
844    ///     .cdp_fix_mode(CdpFixMode::IsolatedWorld)
845    ///     .build();
846    /// assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
847    /// ```
848    #[must_use]
849    pub const fn cdp_fix_mode(mut self, mode: CdpFixMode) -> Self {
850        self.config.cdp_fix_mode = mode;
851        self
852    }
853
854    /// Override the `sourceURL` injected into CDP scripts, or pass `None` to
855    /// disable sourceURL patching.
856    ///
857    /// # Example
858    ///
859    /// ```
860    /// use stygian_browser::BrowserConfig;
861    /// let cfg = BrowserConfig::builder()
862    ///     .source_url(Some("main.js".to_string()))
863    ///     .build();
864    /// assert_eq!(cfg.source_url.as_deref(), Some("main.js"));
865    /// ```
866    #[must_use]
867    pub fn source_url(mut self, url: Option<String>) -> Self {
868        self.config.source_url = url;
869        self
870    }
871
872    /// Override pool settings.
873    #[must_use]
874    pub const fn pool(mut self, pool: PoolConfig) -> Self {
875        self.config.pool = pool;
876        self
877    }
878
879    /// Set a dynamic proxy source for per-instance proxy rotation.
880    ///
881    /// Each new browser launched by the pool calls
882    /// [`ProxySource::bind_proxy`](crate::proxy::ProxySource::bind_proxy) to
883    /// acquire a URL and hold a circuit-breaker lease for the browser's
884    /// lifetime.
885    ///
886    /// # Example
887    ///
888    /// ```rust,no_run
889    /// use std::sync::Arc;
890    /// use stygian_browser::BrowserConfig;
891    ///
892    /// // With stygian_proxy (compile stygian-proxy with `browser` feature):
893    /// // let cfg = BrowserConfig::builder()
894    /// //     .proxy_source(Arc::new(ProxyManagerBridge::new(manager)))
895    /// //     .build();
896    /// ```
897    #[must_use]
898    pub fn proxy_source(mut self, source: Arc<dyn crate::proxy::ProxySource>) -> Self {
899        self.config.proxy_source = Some(source);
900        self
901    }
902
903    /// Build the final [`BrowserConfig`].
904    #[must_use]
905    pub fn build(self) -> BrowserConfig {
906        self.config
907    }
908}
909
910// ─── Serde helpers ────────────────────────────────────────────────────────────
911
912/// Serialize/deserialize `Duration` as integer seconds.
913mod duration_secs {
914    use serde::{Deserialize, Deserializer, Serialize, Serializer};
915    use std::time::Duration;
916
917    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
918        d.as_secs().serialize(s)
919    }
920
921    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Duration, D::Error> {
922        Ok(Duration::from_secs(u64::deserialize(d)?))
923    }
924}
925
926// ─── Env helpers (private) ────────────────────────────────────────────────────
927
928fn env_bool(key: &str, default: bool) -> bool {
929    std::env::var(key).map_or(default, |v| {
930        !matches!(v.to_lowercase().as_str(), "false" | "0" | "no")
931    })
932}
933
934/// Heuristic: returns `true` when the process appears to be running inside a
935/// container (Docker, Kubernetes, etc.) where Chromium's renderer sandbox may
936/// not function because user namespaces are unavailable.
937///
938/// Detection checks (Linux only):
939/// - `/.dockerenv` file exists
940/// - `/proc/1/cgroup` contains "docker" or "kubepods"
941///
942/// On non-Linux platforms this always returns `false` (macOS/Windows have
943/// their own sandbox mechanisms and don't need `--no-sandbox`).
944#[allow(clippy::missing_const_for_fn)] // Linux branch uses runtime file I/O (Path::exists, fs::read_to_string)
945fn is_containerized() -> bool {
946    #[cfg(target_os = "linux")]
947    {
948        if std::path::Path::new("/.dockerenv").exists() {
949            return true;
950        }
951        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
952            && (cgroup.contains("docker") || cgroup.contains("kubepods"))
953        {
954            return true;
955        }
956        false
957    }
958    #[cfg(not(target_os = "linux"))]
959    {
960        false
961    }
962}
963
964fn env_u64(key: &str, default: u64) -> u64 {
965    std::env::var(key)
966        .ok()
967        .and_then(|v| v.parse().ok())
968        .unwrap_or(default)
969}
970
971fn env_usize(key: &str, default: usize) -> usize {
972    std::env::var(key)
973        .ok()
974        .and_then(|v| v.parse().ok())
975        .unwrap_or(default)
976}
977
978// ─── Tests ────────────────────────────────────────────────────────────────────
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983
984    #[test]
985    fn default_config_is_headless() {
986        let cfg = BrowserConfig::default();
987        assert!(cfg.headless);
988    }
989
990    #[test]
991    fn builder_roundtrip() {
992        let cfg = BrowserConfig::builder()
993            .headless(false)
994            .window_size(1280, 720)
995            .stealth_level(StealthLevel::Basic)
996            .build();
997
998        assert!(!cfg.headless);
999        assert_eq!(cfg.window_size, Some((1280, 720)));
1000        assert_eq!(cfg.stealth_level, StealthLevel::Basic);
1001    }
1002
1003    #[test]
1004    fn effective_args_include_anti_detection_flag() {
1005        let cfg = BrowserConfig::default();
1006        let args = cfg.effective_args();
1007        assert!(args.iter().any(|a| a.contains("AutomationControlled")));
1008    }
1009
1010    #[test]
1011    fn no_sandbox_only_when_explicitly_enabled() {
1012        let with_sandbox_disabled = BrowserConfig::builder().disable_sandbox(true).build();
1013        assert!(
1014            with_sandbox_disabled
1015                .effective_args()
1016                .iter()
1017                .any(|a| a == "--no-sandbox")
1018        );
1019
1020        let with_sandbox_enabled = BrowserConfig::builder().disable_sandbox(false).build();
1021        assert!(
1022            !with_sandbox_enabled
1023                .effective_args()
1024                .iter()
1025                .any(|a| a == "--no-sandbox")
1026        );
1027    }
1028
1029    #[test]
1030    fn pool_config_defaults() {
1031        temp_env::with_vars(
1032            [
1033                ("STYGIAN_POOL_MIN", None::<&str>),
1034                ("STYGIAN_POOL_MAX", None::<&str>),
1035                ("STYGIAN_POOL_IDLE_SECS", None::<&str>),
1036                ("STYGIAN_POOL_ACQUIRE_SECS", None::<&str>),
1037            ],
1038            || {
1039                let p = PoolConfig::default();
1040                assert_eq!(p.min_size, 2);
1041                assert_eq!(p.max_size, 10);
1042            },
1043        );
1044    }
1045
1046    #[test]
1047    fn stealth_level_none_not_active() {
1048        assert!(!StealthLevel::None.is_active());
1049        assert!(StealthLevel::Basic.is_active());
1050        assert!(StealthLevel::Advanced.is_active());
1051    }
1052
1053    #[test]
1054    fn config_serialization() -> Result<(), Box<dyn std::error::Error>> {
1055        let cfg = BrowserConfig::default();
1056        let json = serde_json::to_string(&cfg)?;
1057        let back: BrowserConfig = serde_json::from_str(&json)?;
1058        assert_eq!(back.headless, cfg.headless);
1059        assert_eq!(back.stealth_level, cfg.stealth_level);
1060        Ok(())
1061    }
1062
1063    #[test]
1064    fn validate_default_config_is_valid() {
1065        temp_env::with_vars(
1066            [
1067                ("STYGIAN_POOL_MIN", None::<&str>),
1068                ("STYGIAN_POOL_MAX", None::<&str>),
1069                ("STYGIAN_LAUNCH_TIMEOUT_SECS", None::<&str>),
1070                ("STYGIAN_CDP_TIMEOUT_SECS", None::<&str>),
1071                ("STYGIAN_PROXY", None::<&str>),
1072            ],
1073            || {
1074                let cfg = BrowserConfig::default();
1075                assert!(cfg.validate().is_ok(), "default config must be valid");
1076            },
1077        );
1078    }
1079
1080    #[test]
1081    fn validate_detects_pool_size_inversion() {
1082        let cfg = BrowserConfig {
1083            pool: PoolConfig {
1084                min_size: 10,
1085                max_size: 5,
1086                ..PoolConfig::default()
1087            },
1088            ..BrowserConfig::default()
1089        };
1090        let result = cfg.validate();
1091        assert!(result.is_err());
1092        if let Err(errors) = result {
1093            assert!(errors.iter().any(|e| e.contains("min_size")));
1094        }
1095    }
1096
1097    #[test]
1098    fn validate_detects_zero_max_pool() {
1099        let cfg = BrowserConfig {
1100            pool: PoolConfig {
1101                max_size: 0,
1102                ..PoolConfig::default()
1103            },
1104            ..BrowserConfig::default()
1105        };
1106        let result = cfg.validate();
1107        assert!(result.is_err());
1108        if let Err(errors) = result {
1109            assert!(errors.iter().any(|e| e.contains("max_size")));
1110        }
1111    }
1112
1113    #[test]
1114    fn validate_detects_zero_timeouts() {
1115        temp_env::with_vars(
1116            [
1117                ("STYGIAN_POOL_MIN", None::<&str>),
1118                ("STYGIAN_POOL_MAX", None::<&str>),
1119                ("STYGIAN_LAUNCH_TIMEOUT_SECS", None::<&str>),
1120                ("STYGIAN_CDP_TIMEOUT_SECS", None::<&str>),
1121                ("STYGIAN_PROXY", None::<&str>),
1122            ],
1123            || {
1124                let cfg = BrowserConfig {
1125                    launch_timeout: std::time::Duration::ZERO,
1126                    cdp_timeout: std::time::Duration::ZERO,
1127                    ..BrowserConfig::default()
1128                };
1129                let result = cfg.validate();
1130                assert!(result.is_err());
1131                if let Err(errors) = result {
1132                    assert_eq!(errors.len(), 2);
1133                }
1134            },
1135        );
1136    }
1137
1138    #[test]
1139    fn validate_detects_bad_proxy_scheme() {
1140        let cfg = BrowserConfig {
1141            proxy: Some("ftp://bad.proxy:1234".to_string()),
1142            ..BrowserConfig::default()
1143        };
1144        let result = cfg.validate();
1145        assert!(result.is_err());
1146        if let Err(errors) = result {
1147            assert!(errors.iter().any(|e| e.contains("proxy URL")));
1148        }
1149    }
1150
1151    #[test]
1152    fn validate_accepts_valid_proxy() {
1153        let cfg = BrowserConfig {
1154            proxy: Some("socks5://user:pass@127.0.0.1:1080".to_string()),
1155            ..BrowserConfig::default()
1156        };
1157        assert!(cfg.validate().is_ok());
1158    }
1159
1160    #[test]
1161    fn to_json_and_from_json_str_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
1162        let cfg = BrowserConfig::builder()
1163            .headless(false)
1164            .stealth_level(StealthLevel::Basic)
1165            .build();
1166        let json = cfg.to_json()?;
1167        assert!(json.contains("headless"));
1168        let back = BrowserConfig::from_json_str(&json)?;
1169        assert!(!back.headless);
1170        assert_eq!(back.stealth_level, StealthLevel::Basic);
1171        Ok(())
1172    }
1173
1174    #[test]
1175    fn from_json_str_error_on_invalid_json() {
1176        let err = BrowserConfig::from_json_str("not json at all");
1177        assert!(err.is_err());
1178    }
1179
1180    #[test]
1181    fn builder_cdp_fix_mode_and_source_url() {
1182        use crate::cdp_protection::CdpFixMode;
1183        let cfg = BrowserConfig::builder()
1184            .cdp_fix_mode(CdpFixMode::IsolatedWorld)
1185            .source_url(Some("stealth.js".to_string()))
1186            .build();
1187        assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
1188        assert_eq!(cfg.source_url.as_deref(), Some("stealth.js"));
1189    }
1190
1191    #[test]
1192    fn builder_source_url_none_disables_sourceurl() {
1193        let cfg = BrowserConfig::builder().source_url(None).build();
1194        assert!(cfg.source_url.is_none());
1195    }
1196
1197    // ─── Env-var override tests ────────────────────────────────────────────────
1198    //
1199    // These tests set env vars and call BrowserConfig::default() to verify
1200    // the overrides are picked up.  Tests use a per-test unique var name to
1201    // prevent cross-test pollution, but the real STYGIAN_* paths are also
1202    // exercised via a serial test that saves/restores the env.
1203
1204    #[test]
1205    fn stealth_level_from_env_none() {
1206        // env_bool / StealthLevel::from_env are pure functions — we test the
1207        // conversion logic indirectly via a temporary override.
1208        temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("none"), || {
1209            let level = StealthLevel::from_env();
1210            assert_eq!(level, StealthLevel::None);
1211        });
1212    }
1213
1214    #[test]
1215    fn stealth_level_from_env_basic() {
1216        temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("basic"), || {
1217            assert_eq!(StealthLevel::from_env(), StealthLevel::Basic);
1218        });
1219    }
1220
1221    #[test]
1222    fn stealth_level_from_env_advanced_is_default() {
1223        temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("anything_else"), || {
1224            assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
1225        });
1226    }
1227
1228    #[test]
1229    fn stealth_level_from_env_missing_defaults_to_advanced() {
1230        // When the key is absent, from_env() falls through to Advanced.
1231        temp_env::with_var("STYGIAN_STEALTH_LEVEL", None::<&str>, || {
1232            assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
1233        });
1234    }
1235
1236    #[test]
1237    fn cdp_fix_mode_from_env_variants() {
1238        use crate::cdp_protection::CdpFixMode;
1239        let cases = [
1240            ("add_binding", CdpFixMode::AddBinding),
1241            ("isolatedworld", CdpFixMode::IsolatedWorld),
1242            ("enable_disable", CdpFixMode::EnableDisable),
1243            ("none", CdpFixMode::None),
1244            ("unknown_value", CdpFixMode::AddBinding), // falls back to default
1245        ];
1246        for (val, expected) in cases {
1247            temp_env::with_var("STYGIAN_CDP_FIX_MODE", Some(val), || {
1248                // codeql[rust/unused-variable] - `expected` is the `assert_eq!` expected-value argument.
1249                assert_eq!(
1250                    CdpFixMode::from_env(),
1251                    expected,
1252                    "STYGIAN_CDP_FIX_MODE={val}"
1253                );
1254            });
1255        }
1256    }
1257
1258    #[test]
1259    fn pool_config_from_env_min_max() {
1260        temp_env::with_vars(
1261            [
1262                ("STYGIAN_POOL_MIN", Some("3")),
1263                ("STYGIAN_POOL_MAX", Some("15")),
1264            ],
1265            || {
1266                let p = PoolConfig::default();
1267                assert_eq!(p.min_size, 3);
1268                assert_eq!(p.max_size, 15);
1269            },
1270        );
1271    }
1272
1273    #[test]
1274    fn headless_from_env_false() {
1275        temp_env::with_var("STYGIAN_HEADLESS", Some("false"), || {
1276            // env_bool parses the value via BrowserConfig::default()
1277            assert!(!env_bool("STYGIAN_HEADLESS", true));
1278        });
1279    }
1280
1281    #[test]
1282    fn headless_from_env_zero_means_false() {
1283        temp_env::with_var("STYGIAN_HEADLESS", Some("0"), || {
1284            assert!(!env_bool("STYGIAN_HEADLESS", true));
1285        });
1286    }
1287
1288    #[test]
1289    fn headless_from_env_no_means_false() {
1290        temp_env::with_var("STYGIAN_HEADLESS", Some("no"), || {
1291            assert!(!env_bool("STYGIAN_HEADLESS", true));
1292        });
1293    }
1294
1295    #[test]
1296    fn validate_accepts_socks4_proxy() {
1297        let cfg = BrowserConfig {
1298            proxy: Some("socks4://127.0.0.1:1080".to_string()),
1299            ..BrowserConfig::default()
1300        };
1301        assert!(cfg.validate().is_ok());
1302    }
1303
1304    #[test]
1305    fn validate_multiple_errors_returned_together() {
1306        let cfg = BrowserConfig {
1307            pool: PoolConfig {
1308                min_size: 10,
1309                max_size: 5,
1310                ..PoolConfig::default()
1311            },
1312            launch_timeout: std::time::Duration::ZERO,
1313            proxy: Some("ftp://bad".to_string()),
1314            ..BrowserConfig::default()
1315        };
1316        let result = cfg.validate();
1317        assert!(result.is_err());
1318        if let Err(errors) = result {
1319            assert!(errors.len() >= 3, "expected ≥3 errors, got: {errors:?}");
1320        }
1321    }
1322
1323    #[test]
1324    fn json_file_error_on_missing_file() {
1325        let result = BrowserConfig::from_json_file("/nonexistent/path/config.json");
1326        assert!(result.is_err());
1327        if let Err(e) = result {
1328            let err_str = e.to_string();
1329            assert!(err_str.contains("cannot read config file") || err_str.contains("config"));
1330        }
1331    }
1332
1333    #[test]
1334    fn json_roundtrip_preserves_cdp_fix_mode() -> Result<(), Box<dyn std::error::Error>> {
1335        use crate::cdp_protection::CdpFixMode;
1336        let cfg = BrowserConfig::builder()
1337            .cdp_fix_mode(CdpFixMode::EnableDisable)
1338            .build();
1339        let json = cfg.to_json()?;
1340        let back = BrowserConfig::from_json_str(&json)?;
1341        assert_eq!(back.cdp_fix_mode, CdpFixMode::EnableDisable);
1342        Ok(())
1343    }
1344}
1345
1346// ─── temp_env helper (test-only) ─────────────────────────────────────────────
1347//
1348// Lightweight env-var scoping without an external dep.  Uses std::env +
1349// cleanup to isolate side effects.
1350
1351#[cfg(test)]
1352#[allow(unsafe_code)] // env::set_var / remove_var are unsafe in Rust ≥1.93; guarded by ENV_LOCK
1353mod temp_env {
1354    use std::env;
1355    use std::ffi::OsStr;
1356    use std::sync::Mutex;
1357
1358    // Serialise all env-var mutations so parallel tests don't race.
1359    static ENV_LOCK: Mutex<()> = Mutex::new(());
1360
1361    /// Run `f` with the environment variable `key` set to `value` (or unset if
1362    /// `None`), then restore the previous value.
1363    pub fn with_var<K, V, F>(key: K, value: Option<V>, f: F)
1364    where
1365        K: AsRef<OsStr>,
1366        V: AsRef<OsStr>,
1367        F: FnOnce(),
1368    {
1369        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| {
1370            tracing::warn!(
1371                "ENV_LOCK poisoned in with_var: recovering with data from poisoned guard"
1372            );
1373            e.into_inner()
1374        });
1375        let key = key.as_ref();
1376        let prev = env::var_os(key);
1377        match value {
1378            Some(v) => unsafe { env::set_var(key, v.as_ref()) },
1379            None => unsafe { env::remove_var(key) },
1380        }
1381        f();
1382        match prev {
1383            Some(v) => unsafe { env::set_var(key, v) },
1384            None => unsafe { env::remove_var(key) },
1385        }
1386    }
1387
1388    /// Run `f` with multiple env vars set/unset simultaneously.
1389    pub fn with_vars<K, V, F>(pairs: impl IntoIterator<Item = (K, Option<V>)>, f: F)
1390    where
1391        K: AsRef<OsStr>,
1392        V: AsRef<OsStr>,
1393        F: FnOnce(),
1394    {
1395        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| {
1396            tracing::warn!(
1397                "ENV_LOCK poisoned in with_vars: recovering with data from poisoned guard"
1398            );
1399            e.into_inner()
1400        });
1401        let pairs: Vec<_> = pairs
1402            .into_iter()
1403            .map(|(k, v)| {
1404                let key = k.as_ref().to_os_string();
1405                let prev = env::var_os(&key);
1406                let new_val = v.map(|v| v.as_ref().to_os_string());
1407                (key, prev, new_val)
1408            })
1409            .collect();
1410
1411        for (key, _, new_val) in &pairs {
1412            match new_val {
1413                Some(v) => unsafe { env::set_var(key, v) },
1414                None => unsafe { env::remove_var(key) },
1415            }
1416        }
1417
1418        f();
1419
1420        for (key, prev, _) in &pairs {
1421            match prev {
1422                Some(v) => unsafe { env::set_var(key, v) },
1423                None => unsafe { env::remove_var(key) },
1424            }
1425        }
1426    }
1427}