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