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