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