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