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