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}