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