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