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    /// Build an opinionated high-stealth profile for direct traffic (no proxy).
352    ///
353    /// This profile maximizes anti-bot resistance while avoiding proxy-specific
354    /// assumptions:
355    ///
356    /// 1. `StealthLevel::Advanced`
357    /// 2. `HeadlessMode::New`
358    /// 3. `CdpFixMode::AddBinding`
359    /// 4. `WebRtcPolicy::BlockAll` (strongest non-proxy IP leak prevention)
360    /// 5. Default deterministic noise layers enabled
361    /// 6. Weighted coherent fingerprint profile
362    ///
363    /// # Example
364    ///
365    /// ```
366    /// use stygian_browser::BrowserConfig;
367    /// use stygian_browser::config::{HeadlessMode, StealthLevel};
368    ///
369    /// let cfg = BrowserConfig::stealth_profile_without_proxy();
370    /// assert_eq!(cfg.stealth_level, StealthLevel::Advanced);
371    /// assert_eq!(cfg.headless_mode, HeadlessMode::New);
372    /// ```
373    #[cfg(feature = "stealth")]
374    #[must_use]
375    pub fn stealth_profile_without_proxy() -> Self {
376        Self::builder()
377            .stealth_level(StealthLevel::Advanced)
378            .headless_mode(HeadlessMode::New)
379            .cdp_fix_mode(CdpFixMode::AddBinding)
380            .webrtc(crate::webrtc::WebRtcConfig {
381                policy: crate::webrtc::WebRtcPolicy::BlockAll,
382                ..Default::default()
383            })
384            .noise(crate::noise::NoiseConfig::default())
385            .fingerprint_profile(crate::profile::FingerprintProfile::random_weighted())
386            .build()
387    }
388
389    /// Build an opinionated high-stealth profile for proxied traffic.
390    ///
391    /// This profile keeps the same anti-bot posture as
392    /// [`stealth_profile_without_proxy`](Self::stealth_profile_without_proxy)
393    /// and configures proxy-aware WebRTC handling.
394    ///
395    /// 1. `StealthLevel::Advanced`
396    /// 2. `HeadlessMode::New`
397    /// 3. `CdpFixMode::AddBinding`
398    /// 4. `WebRtcPolicy::DisableNonProxied` (prevents direct UDP leaks)
399    /// 5. Default deterministic noise layers enabled
400    /// 6. Weighted coherent fingerprint profile
401    ///
402    /// # Example
403    ///
404    /// ```
405    /// use stygian_browser::BrowserConfig;
406    ///
407    /// let cfg = BrowserConfig::stealth_profile_with_proxy("http://127.0.0.1:8080");
408    /// assert!(cfg.proxy.is_some());
409    /// ```
410    #[cfg(feature = "stealth")]
411    #[must_use]
412    pub fn stealth_profile_with_proxy(proxy_url: impl Into<String>) -> Self {
413        Self::builder()
414            .proxy(proxy_url.into())
415            .stealth_level(StealthLevel::Advanced)
416            .headless_mode(HeadlessMode::New)
417            .cdp_fix_mode(CdpFixMode::AddBinding)
418            .webrtc(crate::webrtc::WebRtcConfig {
419                policy: crate::webrtc::WebRtcPolicy::DisableNonProxied,
420                ..Default::default()
421            })
422            .noise(crate::noise::NoiseConfig::default())
423            .fingerprint_profile(crate::profile::FingerprintProfile::random_weighted())
424            .build()
425    }
426
427    /// Collect the effective Chrome launch arguments.
428    ///
429    /// Returns the anti-detection baseline args merged with any user-supplied
430    /// extras from [`BrowserConfig::args`].
431    pub fn effective_args(&self) -> Vec<String> {
432        let mut args = vec![
433            "--disable-blink-features=AutomationControlled".to_string(),
434            "--disable-dev-shm-usage".to_string(),
435            "--disable-infobars".to_string(),
436            "--disable-background-timer-throttling".to_string(),
437            "--disable-backgrounding-occluded-windows".to_string(),
438            "--disable-renderer-backgrounding".to_string(),
439        ];
440
441        if self.disable_sandbox {
442            args.push("--no-sandbox".to_string());
443        }
444
445        if let Some(proxy) = &self.proxy {
446            args.push(format!("--proxy-server={proxy}"));
447        }
448
449        if let Some(bypass) = &self.proxy_bypass_list {
450            args.push(format!("--proxy-bypass-list={bypass}"));
451        }
452
453        #[cfg(feature = "stealth")]
454        args.extend(self.webrtc.chrome_args());
455
456        if let Some((w, h)) = self.window_size {
457            args.push(format!("--window-size={w},{h}"));
458        }
459
460        args.extend_from_slice(&self.args);
461        args
462    }
463
464    /// Validate the configuration, returning a list of human-readable errors.
465    ///
466    /// Returns `Ok(())` when valid, or `Err(errors)` with a non-empty list.
467    ///
468    /// # Example
469    ///
470    /// ```
471    /// use stygian_browser::BrowserConfig;
472    /// use stygian_browser::config::PoolConfig;
473    /// use std::time::Duration;
474    ///
475    /// let mut cfg = BrowserConfig::default();
476    /// cfg.pool.min_size = 0;
477    /// cfg.pool.max_size = 0; // invalid: max must be >= 1
478    /// let errors = cfg.validate().unwrap_err();
479    /// assert!(!errors.is_empty());
480    /// ```
481    pub fn validate(&self) -> Result<(), Vec<String>> {
482        let mut errors: Vec<String> = Vec::new();
483
484        if self.pool.min_size > self.pool.max_size {
485            errors.push(format!(
486                "pool.min_size ({}) must be <= pool.max_size ({})",
487                self.pool.min_size, self.pool.max_size
488            ));
489        }
490        if self.pool.max_size == 0 {
491            errors.push("pool.max_size must be >= 1".to_string());
492        }
493        if self.launch_timeout.is_zero() {
494            errors.push("launch_timeout must be positive".to_string());
495        }
496        if self.cdp_timeout.is_zero() {
497            errors.push("cdp_timeout must be positive".to_string());
498        }
499        if let Some(proxy) = &self.proxy
500            && !proxy.starts_with("http://")
501            && !proxy.starts_with("https://")
502            && !proxy.starts_with("socks4://")
503            && !proxy.starts_with("socks5://")
504        {
505            errors.push(format!(
506                "proxy URL must start with http://, https://, socks4:// or socks5://; got: {proxy}"
507            ));
508        }
509
510        if errors.is_empty() {
511            Ok(())
512        } else {
513            Err(errors)
514        }
515    }
516
517    /// Serialize this configuration to a JSON string.
518    ///
519    /// # Errors
520    ///
521    /// Returns a [`serde_json::Error`] if serialization fails (very rare).
522    ///
523    /// # Example
524    ///
525    /// ```
526    /// use stygian_browser::BrowserConfig;
527    /// let cfg = BrowserConfig::default();
528    /// let json = cfg.to_json().unwrap();
529    /// assert!(json.contains("headless"));
530    /// ```
531    pub fn to_json(&self) -> Result<String, serde_json::Error> {
532        serde_json::to_string_pretty(self)
533    }
534
535    /// Deserialize a [`BrowserConfig`] from a JSON string.
536    ///
537    /// Environment variable overrides will NOT be re-applied — the JSON values
538    /// are used verbatim.  Chain with builder methods to override individual
539    /// fields after loading.
540    ///
541    /// # Errors
542    ///
543    /// Returns a [`serde_json::Error`] if the input is invalid JSON or has
544    /// missing required fields.
545    ///
546    /// # Example
547    ///
548    /// ```
549    /// use stygian_browser::BrowserConfig;
550    /// let cfg = BrowserConfig::default();
551    /// let json = cfg.to_json().unwrap();
552    /// let back = BrowserConfig::from_json_str(&json).unwrap();
553    /// assert_eq!(back.headless, cfg.headless);
554    /// ```
555    pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
556        serde_json::from_str(s)
557    }
558
559    /// Load a [`BrowserConfig`] from a JSON file on disk.
560    ///
561    /// # Errors
562    ///
563    /// Returns a [`crate::error::BrowserError::ConfigError`] wrapping any I/O
564    /// or parse error.
565    ///
566    /// # Example
567    ///
568    /// ```no_run
569    /// use stygian_browser::BrowserConfig;
570    /// let cfg = BrowserConfig::from_json_file("/etc/stygian/config.json").unwrap();
571    /// ```
572    pub fn from_json_file(path: impl AsRef<std::path::Path>) -> crate::error::Result<Self> {
573        use crate::error::BrowserError;
574        let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
575            BrowserError::ConfigError(format!(
576                "cannot read config file {}: {e}",
577                path.as_ref().display()
578            ))
579        })?;
580        serde_json::from_str(&content).map_err(|e| {
581            BrowserError::ConfigError(format!(
582                "invalid JSON in config file {}: {e}",
583                path.as_ref().display()
584            ))
585        })
586    }
587}
588
589// ─── Builder ──────────────────────────────────────────────────────────────────
590
591/// Fluent builder for [`BrowserConfig`].
592pub struct BrowserConfigBuilder {
593    config: BrowserConfig,
594}
595
596impl BrowserConfigBuilder {
597    /// Set path to the Chrome executable.
598    #[must_use]
599    pub fn chrome_path(mut self, path: PathBuf) -> Self {
600        self.config.chrome_path = Some(path);
601        self
602    }
603
604    /// Set a custom user profile directory.
605    ///
606    /// When not set, each browser instance automatically uses a unique
607    /// temporary directory derived from its instance ID, preventing
608    /// `SingletonLock` races between concurrent pools or instances.
609    ///
610    /// # Example
611    ///
612    /// ```
613    /// use stygian_browser::BrowserConfig;
614    /// let cfg = BrowserConfig::builder()
615    ///     .user_data_dir("/tmp/my-profile")
616    ///     .build();
617    /// assert!(cfg.user_data_dir.is_some());
618    /// ```
619    #[must_use]
620    pub fn user_data_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
621        self.config.user_data_dir = Some(path.into());
622        self
623    }
624
625    /// Set headless mode.
626    #[must_use]
627    pub const fn headless(mut self, headless: bool) -> Self {
628        self.config.headless = headless;
629        self
630    }
631
632    /// Choose between `--headless=new` (default) and the legacy `--headless` flag.
633    ///
634    /// Only relevant when [`headless`][Self::headless] is `true`. Has no effect
635    /// in headed mode.
636    ///
637    /// # Example
638    ///
639    /// ```
640    /// use stygian_browser::BrowserConfig;
641    /// use stygian_browser::config::HeadlessMode;
642    /// let cfg = BrowserConfig::builder()
643    ///     .headless_mode(HeadlessMode::Legacy)
644    ///     .build();
645    /// assert_eq!(cfg.headless_mode, HeadlessMode::Legacy);
646    /// ```
647    #[must_use]
648    pub const fn headless_mode(mut self, mode: HeadlessMode) -> Self {
649        self.config.headless_mode = mode;
650        self
651    }
652
653    /// Set browser viewport / window size.
654    #[must_use]
655    pub const fn window_size(mut self, width: u32, height: u32) -> Self {
656        self.config.window_size = Some((width, height));
657        self
658    }
659
660    /// Enable or disable `DevTools` attachment.
661    #[must_use]
662    pub const fn devtools(mut self, enabled: bool) -> Self {
663        self.config.devtools = enabled;
664        self
665    }
666
667    /// Set proxy URL.
668    #[must_use]
669    pub fn proxy(mut self, proxy: String) -> Self {
670        self.config.proxy = Some(proxy);
671        self
672    }
673
674    /// Set a comma-separated proxy bypass list.
675    ///
676    /// # Example
677    /// ```
678    /// use stygian_browser::BrowserConfig;
679    /// let cfg = BrowserConfig::builder()
680    ///     .proxy("http://proxy:8080".to_string())
681    ///     .proxy_bypass_list("<local>,localhost".to_string())
682    ///     .build();
683    /// assert!(cfg.effective_args().iter().any(|a| a.contains("proxy-bypass")));
684    /// ```
685    #[must_use]
686    pub fn proxy_bypass_list(mut self, bypass: String) -> Self {
687        self.config.proxy_bypass_list = Some(bypass);
688        self
689    }
690
691    /// Set WebRTC IP-leak prevention config.
692    ///
693    /// # Example
694    /// ```
695    /// use stygian_browser::BrowserConfig;
696    /// use stygian_browser::webrtc::{WebRtcConfig, WebRtcPolicy};
697    /// let cfg = BrowserConfig::builder()
698    ///     .webrtc(WebRtcConfig { policy: WebRtcPolicy::BlockAll, ..Default::default() })
699    ///     .build();
700    /// assert!(cfg.effective_args().iter().any(|a| a.contains("disable_non_proxied")));
701    /// ```
702    #[cfg(feature = "stealth")]
703    #[must_use]
704    pub fn webrtc(mut self, webrtc: WebRtcConfig) -> Self {
705        self.config.webrtc = webrtc;
706        self
707    }
708
709    /// Set the fingerprint noise configuration.
710    ///
711    /// # Example
712    /// ```
713    /// use stygian_browser::BrowserConfig;
714    /// use stygian_browser::noise::{NoiseConfig, NoiseSeed};
715    /// let cfg = BrowserConfig::builder()
716    ///     .noise(NoiseConfig { seed: Some(NoiseSeed::from(42_u64)), ..Default::default() })
717    ///     .build();
718    /// assert_eq!(cfg.noise.seed.unwrap().as_u64(), 42);
719    /// ```
720    #[cfg(feature = "stealth")]
721    #[must_use]
722    pub const fn noise(mut self, config: NoiseConfig) -> Self {
723        self.config.noise = config;
724        self
725    }
726
727    /// Set the unified fingerprint profile for coherent identity injection.
728    ///
729    /// # Example
730    /// ```
731    /// use stygian_browser::BrowserConfig;
732    /// use stygian_browser::profile::FingerprintProfile;
733    /// let cfg = BrowserConfig::builder()
734    ///     .fingerprint_profile(FingerprintProfile::windows_chrome_136_rtx3060())
735    ///     .build();
736    /// assert!(cfg.fingerprint_profile.is_some());
737    /// ```
738    #[cfg(feature = "stealth")]
739    #[must_use]
740    pub fn fingerprint_profile(mut self, profile: crate::profile::FingerprintProfile) -> Self {
741        self.config.fingerprint_profile = Some(profile);
742        self
743    }
744
745    /// Set CDP leak hardening configuration.
746    ///
747    /// # Example
748    /// ```
749    /// use stygian_browser::BrowserConfig;
750    /// use stygian_browser::cdp_hardening::CdpHardeningConfig;
751    /// let cfg = BrowserConfig::builder()
752    ///     .cdp_hardening(CdpHardeningConfig { enabled: false, ..Default::default() })
753    ///     .build();
754    /// assert!(!cfg.cdp_hardening.enabled);
755    /// ```
756    #[cfg(feature = "stealth")]
757    #[must_use]
758    pub const fn cdp_hardening(mut self, config: crate::cdp_hardening::CdpHardeningConfig) -> Self {
759        self.config.cdp_hardening = config;
760        self
761    }
762
763    /// Append a custom Chrome argument.
764    #[must_use]
765    pub fn arg(mut self, arg: String) -> Self {
766        self.config.args.push(arg);
767        self
768    }
769
770    /// Add Chrome launch flags that constrain TLS to match a [`TlsProfile`].
771    ///
772    /// Appends version-constraint flags (e.g. `--ssl-version-max=tls1.2`)
773    /// to the extra args list. See [`chrome_tls_args`] for details on what
774    /// Chrome can and cannot control via flags.
775    ///
776    /// [`TlsProfile`]: crate::tls::TlsProfile
777    /// [`chrome_tls_args`]: crate::tls::chrome_tls_args
778    ///
779    /// # Example
780    ///
781    /// ```
782    /// use stygian_browser::BrowserConfig;
783    /// use stygian_browser::tls::CHROME_131;
784    ///
785    /// let cfg = BrowserConfig::builder()
786    ///     .tls_profile(&CHROME_131)
787    ///     .build();
788    /// // Chrome 131 supports both TLS 1.2 and 1.3 — no extra flags needed.
789    /// ```
790    #[cfg(feature = "stealth")]
791    #[must_use]
792    pub fn tls_profile(mut self, profile: &crate::tls::TlsProfile) -> Self {
793        self.config
794            .args
795            .extend(crate::tls::chrome_tls_args(profile));
796        self
797    }
798
799    /// Set the stealth level.
800    #[must_use]
801    pub const fn stealth_level(mut self, level: StealthLevel) -> Self {
802        self.config.stealth_level = level;
803        self
804    }
805
806    /// Explicitly control whether `--no-sandbox` is passed to Chrome.
807    ///
808    /// By default this is auto-detected: `true` inside containers, `false` on
809    /// bare metal. Override only when the auto-detection is wrong.
810    ///
811    /// # Example
812    ///
813    /// ```
814    /// use stygian_browser::BrowserConfig;
815    /// // Force sandbox on (bare-metal host)
816    /// let cfg = BrowserConfig::builder().disable_sandbox(false).build();
817    /// assert!(!cfg.effective_args().iter().any(|a| a == "--no-sandbox"));
818    /// ```
819    #[must_use]
820    pub const fn disable_sandbox(mut self, disable: bool) -> Self {
821        self.config.disable_sandbox = disable;
822        self
823    }
824
825    /// Set the CDP leak-mitigation mode.
826    ///
827    /// # Example
828    ///
829    /// ```
830    /// use stygian_browser::BrowserConfig;
831    /// use stygian_browser::cdp_protection::CdpFixMode;
832    /// let cfg = BrowserConfig::builder()
833    ///     .cdp_fix_mode(CdpFixMode::IsolatedWorld)
834    ///     .build();
835    /// assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
836    /// ```
837    #[must_use]
838    pub const fn cdp_fix_mode(mut self, mode: CdpFixMode) -> Self {
839        self.config.cdp_fix_mode = mode;
840        self
841    }
842
843    /// Override the `sourceURL` injected into CDP scripts, or pass `None` to
844    /// disable sourceURL patching.
845    ///
846    /// # Example
847    ///
848    /// ```
849    /// use stygian_browser::BrowserConfig;
850    /// let cfg = BrowserConfig::builder()
851    ///     .source_url(Some("main.js".to_string()))
852    ///     .build();
853    /// assert_eq!(cfg.source_url.as_deref(), Some("main.js"));
854    /// ```
855    #[must_use]
856    pub fn source_url(mut self, url: Option<String>) -> Self {
857        self.config.source_url = url;
858        self
859    }
860
861    /// Override pool settings.
862    #[must_use]
863    pub const fn pool(mut self, pool: PoolConfig) -> Self {
864        self.config.pool = pool;
865        self
866    }
867
868    /// Set a dynamic proxy source for per-instance proxy rotation.
869    ///
870    /// Each new browser launched by the pool calls
871    /// [`ProxySource::bind_proxy`](crate::proxy::ProxySource::bind_proxy) to
872    /// acquire a URL and hold a circuit-breaker lease for the browser's
873    /// lifetime.
874    ///
875    /// # Example
876    ///
877    /// ```rust,no_run
878    /// use std::sync::Arc;
879    /// use stygian_browser::BrowserConfig;
880    ///
881    /// // With stygian_proxy (compile stygian-proxy with `browser` feature):
882    /// // let cfg = BrowserConfig::builder()
883    /// //     .proxy_source(Arc::new(ProxyManagerBridge::new(manager)))
884    /// //     .build();
885    /// ```
886    #[must_use]
887    pub fn proxy_source(mut self, source: Arc<dyn crate::proxy::ProxySource>) -> Self {
888        self.config.proxy_source = Some(source);
889        self
890    }
891
892    /// Build the final [`BrowserConfig`].
893    pub fn build(self) -> BrowserConfig {
894        self.config
895    }
896}
897
898// ─── Serde helpers ────────────────────────────────────────────────────────────
899
900/// Serialize/deserialize `Duration` as integer seconds.
901mod duration_secs {
902    use serde::{Deserialize, Deserializer, Serialize, Serializer};
903    use std::time::Duration;
904
905    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
906        d.as_secs().serialize(s)
907    }
908
909    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Duration, D::Error> {
910        Ok(Duration::from_secs(u64::deserialize(d)?))
911    }
912}
913
914// ─── Env helpers (private) ────────────────────────────────────────────────────
915
916fn env_bool(key: &str, default: bool) -> bool {
917    std::env::var(key).map_or(default, |v| {
918        !matches!(v.to_lowercase().as_str(), "false" | "0" | "no")
919    })
920}
921
922/// Heuristic: returns `true` when the process appears to be running inside a
923/// container (Docker, Kubernetes, etc.) where Chromium's renderer sandbox may
924/// not function because user namespaces are unavailable.
925///
926/// Detection checks (Linux only):
927/// - `/.dockerenv` file exists
928/// - `/proc/1/cgroup` contains "docker" or "kubepods"
929///
930/// On non-Linux platforms this always returns `false` (macOS/Windows have
931/// their own sandbox mechanisms and don't need `--no-sandbox`).
932#[allow(clippy::missing_const_for_fn)] // Linux branch uses runtime file I/O (Path::exists, fs::read_to_string)
933fn is_containerized() -> bool {
934    #[cfg(target_os = "linux")]
935    {
936        if std::path::Path::new("/.dockerenv").exists() {
937            return true;
938        }
939        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
940            && (cgroup.contains("docker") || cgroup.contains("kubepods"))
941        {
942            return true;
943        }
944        false
945    }
946    #[cfg(not(target_os = "linux"))]
947    {
948        false
949    }
950}
951
952fn env_u64(key: &str, default: u64) -> u64 {
953    std::env::var(key)
954        .ok()
955        .and_then(|v| v.parse().ok())
956        .unwrap_or(default)
957}
958
959fn env_usize(key: &str, default: usize) -> usize {
960    std::env::var(key)
961        .ok()
962        .and_then(|v| v.parse().ok())
963        .unwrap_or(default)
964}
965
966// ─── Tests ────────────────────────────────────────────────────────────────────
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    #[test]
973    fn default_config_is_headless() {
974        let cfg = BrowserConfig::default();
975        assert!(cfg.headless);
976    }
977
978    #[test]
979    fn builder_roundtrip() {
980        let cfg = BrowserConfig::builder()
981            .headless(false)
982            .window_size(1280, 720)
983            .stealth_level(StealthLevel::Basic)
984            .build();
985
986        assert!(!cfg.headless);
987        assert_eq!(cfg.window_size, Some((1280, 720)));
988        assert_eq!(cfg.stealth_level, StealthLevel::Basic);
989    }
990
991    #[test]
992    fn effective_args_include_anti_detection_flag() {
993        let cfg = BrowserConfig::default();
994        let args = cfg.effective_args();
995        assert!(args.iter().any(|a| a.contains("AutomationControlled")));
996    }
997
998    #[test]
999    fn no_sandbox_only_when_explicitly_enabled() {
1000        let with_sandbox_disabled = BrowserConfig::builder().disable_sandbox(true).build();
1001        assert!(
1002            with_sandbox_disabled
1003                .effective_args()
1004                .iter()
1005                .any(|a| a == "--no-sandbox")
1006        );
1007
1008        let with_sandbox_enabled = BrowserConfig::builder().disable_sandbox(false).build();
1009        assert!(
1010            !with_sandbox_enabled
1011                .effective_args()
1012                .iter()
1013                .any(|a| a == "--no-sandbox")
1014        );
1015    }
1016
1017    #[test]
1018    fn pool_config_defaults() {
1019        temp_env::with_vars(
1020            [
1021                ("STYGIAN_POOL_MIN", None::<&str>),
1022                ("STYGIAN_POOL_MAX", None::<&str>),
1023                ("STYGIAN_POOL_IDLE_SECS", None::<&str>),
1024                ("STYGIAN_POOL_ACQUIRE_SECS", None::<&str>),
1025            ],
1026            || {
1027                let p = PoolConfig::default();
1028                assert_eq!(p.min_size, 2);
1029                assert_eq!(p.max_size, 10);
1030            },
1031        );
1032    }
1033
1034    #[test]
1035    fn stealth_level_none_not_active() {
1036        assert!(!StealthLevel::None.is_active());
1037        assert!(StealthLevel::Basic.is_active());
1038        assert!(StealthLevel::Advanced.is_active());
1039    }
1040
1041    #[test]
1042    fn config_serialization() -> Result<(), Box<dyn std::error::Error>> {
1043        let cfg = BrowserConfig::default();
1044        let json = serde_json::to_string(&cfg)?;
1045        let back: BrowserConfig = serde_json::from_str(&json)?;
1046        assert_eq!(back.headless, cfg.headless);
1047        assert_eq!(back.stealth_level, cfg.stealth_level);
1048        Ok(())
1049    }
1050
1051    #[test]
1052    fn validate_default_config_is_valid() {
1053        temp_env::with_vars(
1054            [
1055                ("STYGIAN_POOL_MIN", None::<&str>),
1056                ("STYGIAN_POOL_MAX", None::<&str>),
1057                ("STYGIAN_LAUNCH_TIMEOUT_SECS", None::<&str>),
1058                ("STYGIAN_CDP_TIMEOUT_SECS", None::<&str>),
1059                ("STYGIAN_PROXY", None::<&str>),
1060            ],
1061            || {
1062                let cfg = BrowserConfig::default();
1063                assert!(cfg.validate().is_ok(), "default config must be valid");
1064            },
1065        );
1066    }
1067
1068    #[test]
1069    fn validate_detects_pool_size_inversion() {
1070        let cfg = BrowserConfig {
1071            pool: PoolConfig {
1072                min_size: 10,
1073                max_size: 5,
1074                ..PoolConfig::default()
1075            },
1076            ..BrowserConfig::default()
1077        };
1078        let result = cfg.validate();
1079        assert!(result.is_err());
1080        if let Err(errors) = result {
1081            assert!(errors.iter().any(|e| e.contains("min_size")));
1082        }
1083    }
1084
1085    #[test]
1086    fn validate_detects_zero_max_pool() {
1087        let cfg = BrowserConfig {
1088            pool: PoolConfig {
1089                max_size: 0,
1090                ..PoolConfig::default()
1091            },
1092            ..BrowserConfig::default()
1093        };
1094        let result = cfg.validate();
1095        assert!(result.is_err());
1096        if let Err(errors) = result {
1097            assert!(errors.iter().any(|e| e.contains("max_size")));
1098        }
1099    }
1100
1101    #[test]
1102    fn validate_detects_zero_timeouts() {
1103        temp_env::with_vars(
1104            [
1105                ("STYGIAN_POOL_MIN", None::<&str>),
1106                ("STYGIAN_POOL_MAX", None::<&str>),
1107                ("STYGIAN_LAUNCH_TIMEOUT_SECS", None::<&str>),
1108                ("STYGIAN_CDP_TIMEOUT_SECS", None::<&str>),
1109                ("STYGIAN_PROXY", None::<&str>),
1110            ],
1111            || {
1112                let cfg = BrowserConfig {
1113                    launch_timeout: std::time::Duration::ZERO,
1114                    cdp_timeout: std::time::Duration::ZERO,
1115                    ..BrowserConfig::default()
1116                };
1117                let result = cfg.validate();
1118                assert!(result.is_err());
1119                if let Err(errors) = result {
1120                    assert_eq!(errors.len(), 2);
1121                }
1122            },
1123        );
1124    }
1125
1126    #[test]
1127    fn validate_detects_bad_proxy_scheme() {
1128        let cfg = BrowserConfig {
1129            proxy: Some("ftp://bad.proxy:1234".to_string()),
1130            ..BrowserConfig::default()
1131        };
1132        let result = cfg.validate();
1133        assert!(result.is_err());
1134        if let Err(errors) = result {
1135            assert!(errors.iter().any(|e| e.contains("proxy URL")));
1136        }
1137    }
1138
1139    #[test]
1140    fn validate_accepts_valid_proxy() {
1141        let cfg = BrowserConfig {
1142            proxy: Some("socks5://user:pass@127.0.0.1:1080".to_string()),
1143            ..BrowserConfig::default()
1144        };
1145        assert!(cfg.validate().is_ok());
1146    }
1147
1148    #[test]
1149    fn to_json_and_from_json_str_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
1150        let cfg = BrowserConfig::builder()
1151            .headless(false)
1152            .stealth_level(StealthLevel::Basic)
1153            .build();
1154        let json = cfg.to_json()?;
1155        assert!(json.contains("headless"));
1156        let back = BrowserConfig::from_json_str(&json)?;
1157        assert!(!back.headless);
1158        assert_eq!(back.stealth_level, StealthLevel::Basic);
1159        Ok(())
1160    }
1161
1162    #[test]
1163    fn from_json_str_error_on_invalid_json() {
1164        let err = BrowserConfig::from_json_str("not json at all");
1165        assert!(err.is_err());
1166    }
1167
1168    #[test]
1169    fn builder_cdp_fix_mode_and_source_url() {
1170        use crate::cdp_protection::CdpFixMode;
1171        let cfg = BrowserConfig::builder()
1172            .cdp_fix_mode(CdpFixMode::IsolatedWorld)
1173            .source_url(Some("stealth.js".to_string()))
1174            .build();
1175        assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
1176        assert_eq!(cfg.source_url.as_deref(), Some("stealth.js"));
1177    }
1178
1179    #[test]
1180    fn builder_source_url_none_disables_sourceurl() {
1181        let cfg = BrowserConfig::builder().source_url(None).build();
1182        assert!(cfg.source_url.is_none());
1183    }
1184
1185    // ─── Env-var override tests ────────────────────────────────────────────────
1186    //
1187    // These tests set env vars and call BrowserConfig::default() to verify
1188    // the overrides are picked up.  Tests use a per-test unique var name to
1189    // prevent cross-test pollution, but the real STYGIAN_* paths are also
1190    // exercised via a serial test that saves/restores the env.
1191
1192    #[test]
1193    fn stealth_level_from_env_none() {
1194        // env_bool / StealthLevel::from_env are pure functions — we test the
1195        // conversion logic indirectly via a temporary override.
1196        temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("none"), || {
1197            let level = StealthLevel::from_env();
1198            assert_eq!(level, StealthLevel::None);
1199        });
1200    }
1201
1202    #[test]
1203    fn stealth_level_from_env_basic() {
1204        temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("basic"), || {
1205            assert_eq!(StealthLevel::from_env(), StealthLevel::Basic);
1206        });
1207    }
1208
1209    #[test]
1210    fn stealth_level_from_env_advanced_is_default() {
1211        temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("anything_else"), || {
1212            assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
1213        });
1214    }
1215
1216    #[test]
1217    fn stealth_level_from_env_missing_defaults_to_advanced() {
1218        // When the key is absent, from_env() falls through to Advanced.
1219        temp_env::with_var("STYGIAN_STEALTH_LEVEL", None::<&str>, || {
1220            assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
1221        });
1222    }
1223
1224    #[test]
1225    fn cdp_fix_mode_from_env_variants() {
1226        use crate::cdp_protection::CdpFixMode;
1227        let cases = [
1228            ("add_binding", CdpFixMode::AddBinding),
1229            ("isolatedworld", CdpFixMode::IsolatedWorld),
1230            ("enable_disable", CdpFixMode::EnableDisable),
1231            ("none", CdpFixMode::None),
1232            ("unknown_value", CdpFixMode::AddBinding), // falls back to default
1233        ];
1234        for (val, expected) in cases {
1235            temp_env::with_var("STYGIAN_CDP_FIX_MODE", Some(val), || {
1236                assert_eq!(
1237                    CdpFixMode::from_env(),
1238                    expected,
1239                    "STYGIAN_CDP_FIX_MODE={val}"
1240                );
1241            });
1242        }
1243    }
1244
1245    #[test]
1246    fn pool_config_from_env_min_max() {
1247        temp_env::with_vars(
1248            [
1249                ("STYGIAN_POOL_MIN", Some("3")),
1250                ("STYGIAN_POOL_MAX", Some("15")),
1251            ],
1252            || {
1253                let p = PoolConfig::default();
1254                assert_eq!(p.min_size, 3);
1255                assert_eq!(p.max_size, 15);
1256            },
1257        );
1258    }
1259
1260    #[test]
1261    fn headless_from_env_false() {
1262        temp_env::with_var("STYGIAN_HEADLESS", Some("false"), || {
1263            // env_bool parses the value via BrowserConfig::default()
1264            assert!(!env_bool("STYGIAN_HEADLESS", true));
1265        });
1266    }
1267
1268    #[test]
1269    fn headless_from_env_zero_means_false() {
1270        temp_env::with_var("STYGIAN_HEADLESS", Some("0"), || {
1271            assert!(!env_bool("STYGIAN_HEADLESS", true));
1272        });
1273    }
1274
1275    #[test]
1276    fn headless_from_env_no_means_false() {
1277        temp_env::with_var("STYGIAN_HEADLESS", Some("no"), || {
1278            assert!(!env_bool("STYGIAN_HEADLESS", true));
1279        });
1280    }
1281
1282    #[test]
1283    fn validate_accepts_socks4_proxy() {
1284        let cfg = BrowserConfig {
1285            proxy: Some("socks4://127.0.0.1:1080".to_string()),
1286            ..BrowserConfig::default()
1287        };
1288        assert!(cfg.validate().is_ok());
1289    }
1290
1291    #[test]
1292    fn validate_multiple_errors_returned_together() {
1293        let cfg = BrowserConfig {
1294            pool: PoolConfig {
1295                min_size: 10,
1296                max_size: 5,
1297                ..PoolConfig::default()
1298            },
1299            launch_timeout: std::time::Duration::ZERO,
1300            proxy: Some("ftp://bad".to_string()),
1301            ..BrowserConfig::default()
1302        };
1303        let result = cfg.validate();
1304        assert!(result.is_err());
1305        if let Err(errors) = result {
1306            assert!(errors.len() >= 3, "expected ≥3 errors, got: {errors:?}");
1307        }
1308    }
1309
1310    #[test]
1311    fn json_file_error_on_missing_file() {
1312        let result = BrowserConfig::from_json_file("/nonexistent/path/config.json");
1313        assert!(result.is_err());
1314        if let Err(e) = result {
1315            let err_str = e.to_string();
1316            assert!(err_str.contains("cannot read config file") || err_str.contains("config"));
1317        }
1318    }
1319
1320    #[test]
1321    fn json_roundtrip_preserves_cdp_fix_mode() -> Result<(), Box<dyn std::error::Error>> {
1322        use crate::cdp_protection::CdpFixMode;
1323        let cfg = BrowserConfig::builder()
1324            .cdp_fix_mode(CdpFixMode::EnableDisable)
1325            .build();
1326        let json = cfg.to_json()?;
1327        let back = BrowserConfig::from_json_str(&json)?;
1328        assert_eq!(back.cdp_fix_mode, CdpFixMode::EnableDisable);
1329        Ok(())
1330    }
1331}
1332
1333// ─── temp_env helper (test-only) ─────────────────────────────────────────────
1334//
1335// Lightweight env-var scoping without an external dep.  Uses std::env +
1336// cleanup to isolate side effects.
1337
1338#[cfg(test)]
1339#[allow(unsafe_code)] // env::set_var / remove_var are unsafe in Rust ≥1.93; guarded by ENV_LOCK
1340mod temp_env {
1341    use std::env;
1342    use std::ffi::OsStr;
1343    use std::sync::Mutex;
1344
1345    // Serialise all env-var mutations so parallel tests don't race.
1346    static ENV_LOCK: Mutex<()> = Mutex::new(());
1347
1348    /// Run `f` with the environment variable `key` set to `value` (or unset if
1349    /// `None`), then restore the previous value.
1350    pub fn with_var<K, V, F>(key: K, value: Option<V>, f: F)
1351    where
1352        K: AsRef<OsStr>,
1353        V: AsRef<OsStr>,
1354        F: FnOnce(),
1355    {
1356        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| {
1357            tracing::warn!(
1358                "ENV_LOCK poisoned in with_var: recovering with data from poisoned guard"
1359            );
1360            e.into_inner()
1361        });
1362        let key = key.as_ref();
1363        let prev = env::var_os(key);
1364        match value {
1365            Some(v) => unsafe { env::set_var(key, v.as_ref()) },
1366            None => unsafe { env::remove_var(key) },
1367        }
1368        f();
1369        match prev {
1370            Some(v) => unsafe { env::set_var(key, v) },
1371            None => unsafe { env::remove_var(key) },
1372        }
1373    }
1374
1375    /// Run `f` with multiple env vars set/unset simultaneously.
1376    pub fn with_vars<K, V, F>(pairs: impl IntoIterator<Item = (K, Option<V>)>, f: F)
1377    where
1378        K: AsRef<OsStr>,
1379        V: AsRef<OsStr>,
1380        F: FnOnce(),
1381    {
1382        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| {
1383            tracing::warn!(
1384                "ENV_LOCK poisoned in with_vars: recovering with data from poisoned guard"
1385            );
1386            e.into_inner()
1387        });
1388        let pairs: Vec<_> = pairs
1389            .into_iter()
1390            .map(|(k, v)| {
1391                let key = k.as_ref().to_os_string();
1392                let prev = env::var_os(&key);
1393                let new_val = v.map(|v| v.as_ref().to_os_string());
1394                (key, prev, new_val)
1395            })
1396            .collect();
1397
1398        for (key, _, new_val) in &pairs {
1399            match new_val {
1400                Some(v) => unsafe { env::set_var(key, v) },
1401                None => unsafe { env::remove_var(key) },
1402            }
1403        }
1404
1405        f();
1406
1407        for (key, prev, _) in &pairs {
1408            match prev {
1409                Some(v) => unsafe { env::set_var(key, v) },
1410                None => unsafe { env::remove_var(key) },
1411            }
1412        }
1413    }
1414}