1use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16use std::time::Duration;
17
18use crate::cdp_protection::CdpFixMode;
19
20#[cfg(feature = "stealth")]
21use crate::webrtc::WebRtcConfig;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
38#[serde(rename_all = "lowercase")]
39pub enum StealthLevel {
40 None,
42 Basic,
44 #[default]
46 Advanced,
47}
48
49impl StealthLevel {
50 #[must_use]
52 pub fn is_active(self) -> bool {
53 self != Self::None
54 }
55
56 pub fn from_env() -> Self {
58 match std::env::var("STYGIAN_STEALTH_LEVEL")
59 .unwrap_or_default()
60 .to_lowercase()
61 .as_str()
62 {
63 "none" => Self::None,
64 "basic" => Self::Basic,
65 _ => Self::Advanced,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct PoolConfig {
84 pub min_size: usize,
88
89 pub max_size: usize,
93
94 #[serde(with = "duration_secs")]
98 pub idle_timeout: Duration,
99
100 #[serde(with = "duration_secs")]
105 pub acquire_timeout: Duration,
106}
107
108impl Default for PoolConfig {
109 fn default() -> Self {
110 Self {
111 min_size: env_usize("STYGIAN_POOL_MIN", 2),
112 max_size: env_usize("STYGIAN_POOL_MAX", 10),
113 idle_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_IDLE_SECS", 300)),
114 acquire_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_ACQUIRE_SECS", 5)),
115 }
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct BrowserConfig {
137 pub chrome_path: Option<PathBuf>,
141
142 pub args: Vec<String>,
144
145 pub headless: bool,
149
150 pub user_data_dir: Option<PathBuf>,
152
153 pub window_size: Option<(u32, u32)>,
155
156 pub devtools: bool,
158
159 pub proxy: Option<String>,
161
162 pub proxy_bypass_list: Option<String>,
166
167 #[cfg(feature = "stealth")]
171 pub webrtc: WebRtcConfig,
172
173 pub stealth_level: StealthLevel,
175
176 pub cdp_fix_mode: CdpFixMode,
180
181 pub source_url: Option<String>,
188
189 pub pool: PoolConfig,
191
192 #[serde(with = "duration_secs")]
196 pub launch_timeout: Duration,
197
198 #[serde(with = "duration_secs")]
202 pub cdp_timeout: Duration,
203}
204
205impl Default for BrowserConfig {
206 fn default() -> Self {
207 Self {
208 chrome_path: std::env::var("STYGIAN_CHROME_PATH").ok().map(PathBuf::from),
209 args: vec![],
210 headless: env_bool("STYGIAN_HEADLESS", true),
211 user_data_dir: None,
212 window_size: Some((1920, 1080)),
213 devtools: false,
214 proxy: std::env::var("STYGIAN_PROXY").ok(),
215 proxy_bypass_list: std::env::var("STYGIAN_PROXY_BYPASS").ok(),
216 #[cfg(feature = "stealth")]
217 webrtc: WebRtcConfig::default(),
218 stealth_level: StealthLevel::from_env(),
219 cdp_fix_mode: CdpFixMode::from_env(),
220 source_url: std::env::var("STYGIAN_SOURCE_URL").ok(),
221 pool: PoolConfig::default(),
222 launch_timeout: Duration::from_secs(env_u64("STYGIAN_LAUNCH_TIMEOUT_SECS", 10)),
223 cdp_timeout: Duration::from_secs(env_u64("STYGIAN_CDP_TIMEOUT_SECS", 30)),
224 }
225 }
226}
227
228impl BrowserConfig {
229 pub fn builder() -> BrowserConfigBuilder {
231 BrowserConfigBuilder {
232 config: Self::default(),
233 }
234 }
235
236 pub fn effective_args(&self) -> Vec<String> {
241 let mut args = vec![
242 "--disable-blink-features=AutomationControlled".to_string(),
243 "--disable-dev-shm-usage".to_string(),
244 "--no-sandbox".to_string(),
245 "--disable-infobars".to_string(),
246 "--disable-background-timer-throttling".to_string(),
247 "--disable-backgrounding-occluded-windows".to_string(),
248 "--disable-renderer-backgrounding".to_string(),
249 ];
250
251 if let Some(proxy) = &self.proxy {
252 args.push(format!("--proxy-server={proxy}"));
253 }
254
255 if let Some(bypass) = &self.proxy_bypass_list {
256 args.push(format!("--proxy-bypass-list={bypass}"));
257 }
258
259 #[cfg(feature = "stealth")]
260 args.extend(self.webrtc.chrome_args());
261
262 if let Some((w, h)) = self.window_size {
263 args.push(format!("--window-size={w},{h}"));
264 }
265
266 args.extend_from_slice(&self.args);
267 args
268 }
269
270 pub fn validate(&self) -> Result<(), Vec<String>> {
288 let mut errors: Vec<String> = Vec::new();
289
290 if self.pool.min_size > self.pool.max_size {
291 errors.push(format!(
292 "pool.min_size ({}) must be <= pool.max_size ({})",
293 self.pool.min_size, self.pool.max_size
294 ));
295 }
296 if self.pool.max_size == 0 {
297 errors.push("pool.max_size must be >= 1".to_string());
298 }
299 if self.launch_timeout.is_zero() {
300 errors.push("launch_timeout must be positive".to_string());
301 }
302 if self.cdp_timeout.is_zero() {
303 errors.push("cdp_timeout must be positive".to_string());
304 }
305 if let Some(proxy) = &self.proxy
306 && !proxy.starts_with("http://")
307 && !proxy.starts_with("https://")
308 && !proxy.starts_with("socks4://")
309 && !proxy.starts_with("socks5://")
310 {
311 errors.push(format!(
312 "proxy URL must start with http://, https://, socks4:// or socks5://; got: {proxy}"
313 ));
314 }
315
316 if errors.is_empty() {
317 Ok(())
318 } else {
319 Err(errors)
320 }
321 }
322
323 pub fn to_json(&self) -> Result<String, serde_json::Error> {
338 serde_json::to_string_pretty(self)
339 }
340
341 pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
362 serde_json::from_str(s)
363 }
364
365 pub fn from_json_file(path: impl AsRef<std::path::Path>) -> crate::error::Result<Self> {
379 use crate::error::BrowserError;
380 let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
381 BrowserError::ConfigError(format!(
382 "cannot read config file {}: {e}",
383 path.as_ref().display()
384 ))
385 })?;
386 serde_json::from_str(&content).map_err(|e| {
387 BrowserError::ConfigError(format!(
388 "invalid JSON in config file {}: {e}",
389 path.as_ref().display()
390 ))
391 })
392 }
393}
394
395pub struct BrowserConfigBuilder {
399 config: BrowserConfig,
400}
401
402impl BrowserConfigBuilder {
403 #[must_use]
405 pub fn chrome_path(mut self, path: PathBuf) -> Self {
406 self.config.chrome_path = Some(path);
407 self
408 }
409
410 #[must_use]
412 pub const fn headless(mut self, headless: bool) -> Self {
413 self.config.headless = headless;
414 self
415 }
416
417 #[must_use]
419 pub const fn window_size(mut self, width: u32, height: u32) -> Self {
420 self.config.window_size = Some((width, height));
421 self
422 }
423
424 #[must_use]
426 pub const fn devtools(mut self, enabled: bool) -> Self {
427 self.config.devtools = enabled;
428 self
429 }
430
431 #[must_use]
433 pub fn proxy(mut self, proxy: String) -> Self {
434 self.config.proxy = Some(proxy);
435 self
436 }
437
438 #[must_use]
450 pub fn proxy_bypass_list(mut self, bypass: String) -> Self {
451 self.config.proxy_bypass_list = Some(bypass);
452 self
453 }
454
455 #[cfg(feature = "stealth")]
467 #[must_use]
468 pub fn webrtc(mut self, webrtc: WebRtcConfig) -> Self {
469 self.config.webrtc = webrtc;
470 self
471 }
472
473 #[must_use]
475 pub fn arg(mut self, arg: String) -> Self {
476 self.config.args.push(arg);
477 self
478 }
479
480 #[must_use]
482 pub const fn stealth_level(mut self, level: StealthLevel) -> Self {
483 self.config.stealth_level = level;
484 self
485 }
486
487 #[must_use]
500 pub const fn cdp_fix_mode(mut self, mode: CdpFixMode) -> Self {
501 self.config.cdp_fix_mode = mode;
502 self
503 }
504
505 #[must_use]
518 pub fn source_url(mut self, url: Option<String>) -> Self {
519 self.config.source_url = url;
520 self
521 }
522
523 #[must_use]
525 pub const fn pool(mut self, pool: PoolConfig) -> Self {
526 self.config.pool = pool;
527 self
528 }
529
530 pub fn build(self) -> BrowserConfig {
532 self.config
533 }
534}
535
536mod duration_secs {
540 use serde::{Deserialize, Deserializer, Serialize, Serializer};
541 use std::time::Duration;
542
543 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
544 d.as_secs().serialize(s)
545 }
546
547 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Duration, D::Error> {
548 Ok(Duration::from_secs(u64::deserialize(d)?))
549 }
550}
551
552fn env_bool(key: &str, default: bool) -> bool {
555 std::env::var(key)
556 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "0" | "no"))
557 .unwrap_or(default)
558}
559
560fn env_u64(key: &str, default: u64) -> u64 {
561 std::env::var(key)
562 .ok()
563 .and_then(|v| v.parse().ok())
564 .unwrap_or(default)
565}
566
567fn env_usize(key: &str, default: usize) -> usize {
568 std::env::var(key)
569 .ok()
570 .and_then(|v| v.parse().ok())
571 .unwrap_or(default)
572}
573
574#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn default_config_is_headless() {
582 let cfg = BrowserConfig::default();
583 assert!(cfg.headless);
584 }
585
586 #[test]
587 fn builder_roundtrip() {
588 let cfg = BrowserConfig::builder()
589 .headless(false)
590 .window_size(1280, 720)
591 .stealth_level(StealthLevel::Basic)
592 .build();
593
594 assert!(!cfg.headless);
595 assert_eq!(cfg.window_size, Some((1280, 720)));
596 assert_eq!(cfg.stealth_level, StealthLevel::Basic);
597 }
598
599 #[test]
600 fn effective_args_include_anti_detection_flag() {
601 let cfg = BrowserConfig::default();
602 let args = cfg.effective_args();
603 assert!(args.iter().any(|a| a.contains("AutomationControlled")));
604 }
605
606 #[test]
607 fn pool_config_defaults() {
608 let p = PoolConfig::default();
609 assert_eq!(p.min_size, 2);
610 assert_eq!(p.max_size, 10);
611 }
612
613 #[test]
614 fn stealth_level_none_not_active() {
615 assert!(!StealthLevel::None.is_active());
616 assert!(StealthLevel::Basic.is_active());
617 assert!(StealthLevel::Advanced.is_active());
618 }
619
620 #[test]
621 fn config_serialization() -> Result<(), Box<dyn std::error::Error>> {
622 let cfg = BrowserConfig::default();
623 let json = serde_json::to_string(&cfg)?;
624 let back: BrowserConfig = serde_json::from_str(&json)?;
625 assert_eq!(back.headless, cfg.headless);
626 assert_eq!(back.stealth_level, cfg.stealth_level);
627 Ok(())
628 }
629
630 #[test]
631 fn validate_default_config_is_valid() {
632 let cfg = BrowserConfig::default();
633 assert!(cfg.validate().is_ok(), "default config must be valid");
634 }
635
636 #[test]
637 fn validate_detects_pool_size_inversion() {
638 let cfg = BrowserConfig {
639 pool: PoolConfig {
640 min_size: 10,
641 max_size: 5,
642 ..PoolConfig::default()
643 },
644 ..BrowserConfig::default()
645 };
646 let result = cfg.validate();
647 assert!(result.is_err());
648 if let Err(errors) = result {
649 assert!(errors.iter().any(|e| e.contains("min_size")));
650 }
651 }
652
653 #[test]
654 fn validate_detects_zero_max_pool() {
655 let cfg = BrowserConfig {
656 pool: PoolConfig {
657 max_size: 0,
658 ..PoolConfig::default()
659 },
660 ..BrowserConfig::default()
661 };
662 let result = cfg.validate();
663 assert!(result.is_err());
664 if let Err(errors) = result {
665 assert!(errors.iter().any(|e| e.contains("max_size")));
666 }
667 }
668
669 #[test]
670 fn validate_detects_zero_timeouts() {
671 let cfg = BrowserConfig {
672 launch_timeout: std::time::Duration::ZERO,
673 cdp_timeout: std::time::Duration::ZERO,
674 ..BrowserConfig::default()
675 };
676 let result = cfg.validate();
677 assert!(result.is_err());
678 if let Err(errors) = result {
679 assert_eq!(errors.len(), 2);
680 }
681 }
682
683 #[test]
684 fn validate_detects_bad_proxy_scheme() {
685 let cfg = BrowserConfig {
686 proxy: Some("ftp://bad.proxy:1234".to_string()),
687 ..BrowserConfig::default()
688 };
689 let result = cfg.validate();
690 assert!(result.is_err());
691 if let Err(errors) = result {
692 assert!(errors.iter().any(|e| e.contains("proxy URL")));
693 }
694 }
695
696 #[test]
697 fn validate_accepts_valid_proxy() {
698 let cfg = BrowserConfig {
699 proxy: Some("socks5://user:pass@127.0.0.1:1080".to_string()),
700 ..BrowserConfig::default()
701 };
702 assert!(cfg.validate().is_ok());
703 }
704
705 #[test]
706 fn to_json_and_from_json_str_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
707 let cfg = BrowserConfig::builder()
708 .headless(false)
709 .stealth_level(StealthLevel::Basic)
710 .build();
711 let json = cfg.to_json()?;
712 assert!(json.contains("headless"));
713 let back = BrowserConfig::from_json_str(&json)?;
714 assert!(!back.headless);
715 assert_eq!(back.stealth_level, StealthLevel::Basic);
716 Ok(())
717 }
718
719 #[test]
720 fn from_json_str_error_on_invalid_json() {
721 let err = BrowserConfig::from_json_str("not json at all");
722 assert!(err.is_err());
723 }
724
725 #[test]
726 fn builder_cdp_fix_mode_and_source_url() {
727 use crate::cdp_protection::CdpFixMode;
728 let cfg = BrowserConfig::builder()
729 .cdp_fix_mode(CdpFixMode::IsolatedWorld)
730 .source_url(Some("stealth.js".to_string()))
731 .build();
732 assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
733 assert_eq!(cfg.source_url.as_deref(), Some("stealth.js"));
734 }
735
736 #[test]
737 fn builder_source_url_none_disables_sourceurl() {
738 let cfg = BrowserConfig::builder().source_url(None).build();
739 assert!(cfg.source_url.is_none());
740 }
741
742 #[test]
750 fn stealth_level_from_env_none() {
751 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("none"), || {
754 let level = StealthLevel::from_env();
755 assert_eq!(level, StealthLevel::None);
756 });
757 }
758
759 #[test]
760 fn stealth_level_from_env_basic() {
761 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("basic"), || {
762 assert_eq!(StealthLevel::from_env(), StealthLevel::Basic);
763 });
764 }
765
766 #[test]
767 fn stealth_level_from_env_advanced_is_default() {
768 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("anything_else"), || {
769 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
770 });
771 }
772
773 #[test]
774 fn stealth_level_from_env_missing_defaults_to_advanced() {
775 temp_env::with_var("STYGIAN_STEALTH_LEVEL", None::<&str>, || {
777 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
778 });
779 }
780
781 #[test]
782 fn cdp_fix_mode_from_env_variants() {
783 use crate::cdp_protection::CdpFixMode;
784 let cases = [
785 ("add_binding", CdpFixMode::AddBinding),
786 ("isolatedworld", CdpFixMode::IsolatedWorld),
787 ("enable_disable", CdpFixMode::EnableDisable),
788 ("none", CdpFixMode::None),
789 ("unknown_value", CdpFixMode::AddBinding), ];
791 for (val, expected) in cases {
792 temp_env::with_var("STYGIAN_CDP_FIX_MODE", Some(val), || {
793 assert_eq!(
794 CdpFixMode::from_env(),
795 expected,
796 "STYGIAN_CDP_FIX_MODE={val}"
797 );
798 });
799 }
800 }
801
802 #[test]
803 fn pool_config_from_env_min_max() {
804 temp_env::with_vars(
805 [
806 ("STYGIAN_POOL_MIN", Some("3")),
807 ("STYGIAN_POOL_MAX", Some("15")),
808 ],
809 || {
810 let p = PoolConfig::default();
811 assert_eq!(p.min_size, 3);
812 assert_eq!(p.max_size, 15);
813 },
814 );
815 }
816
817 #[test]
818 fn headless_from_env_false() {
819 temp_env::with_var("STYGIAN_HEADLESS", Some("false"), || {
820 assert!(!env_bool("STYGIAN_HEADLESS", true));
822 });
823 }
824
825 #[test]
826 fn headless_from_env_zero_means_false() {
827 temp_env::with_var("STYGIAN_HEADLESS", Some("0"), || {
828 assert!(!env_bool("STYGIAN_HEADLESS", true));
829 });
830 }
831
832 #[test]
833 fn headless_from_env_no_means_false() {
834 temp_env::with_var("STYGIAN_HEADLESS", Some("no"), || {
835 assert!(!env_bool("STYGIAN_HEADLESS", true));
836 });
837 }
838
839 #[test]
840 fn validate_accepts_socks4_proxy() {
841 let cfg = BrowserConfig {
842 proxy: Some("socks4://127.0.0.1:1080".to_string()),
843 ..BrowserConfig::default()
844 };
845 assert!(cfg.validate().is_ok());
846 }
847
848 #[test]
849 fn validate_multiple_errors_returned_together() {
850 let cfg = BrowserConfig {
851 pool: PoolConfig {
852 min_size: 10,
853 max_size: 5,
854 ..PoolConfig::default()
855 },
856 launch_timeout: std::time::Duration::ZERO,
857 proxy: Some("ftp://bad".to_string()),
858 ..BrowserConfig::default()
859 };
860 let result = cfg.validate();
861 assert!(result.is_err());
862 if let Err(errors) = result {
863 assert!(errors.len() >= 3, "expected ≥3 errors, got: {errors:?}");
864 }
865 }
866
867 #[test]
868 fn json_file_error_on_missing_file() {
869 let result = BrowserConfig::from_json_file("/nonexistent/path/config.json");
870 assert!(result.is_err());
871 if let Err(e) = result {
872 let err_str = e.to_string();
873 assert!(err_str.contains("cannot read config file") || err_str.contains("config"));
874 }
875 }
876
877 #[test]
878 fn json_roundtrip_preserves_cdp_fix_mode() -> Result<(), Box<dyn std::error::Error>> {
879 use crate::cdp_protection::CdpFixMode;
880 let cfg = BrowserConfig::builder()
881 .cdp_fix_mode(CdpFixMode::EnableDisable)
882 .build();
883 let json = cfg.to_json()?;
884 let back = BrowserConfig::from_json_str(&json)?;
885 assert_eq!(back.cdp_fix_mode, CdpFixMode::EnableDisable);
886 Ok(())
887 }
888}
889
890#[cfg(test)]
896mod temp_env {
897 use std::env;
898 use std::ffi::OsStr;
899 use std::sync::Mutex;
900
901 static ENV_LOCK: Mutex<()> = Mutex::new(());
903
904 pub fn with_var<K, V, F>(key: K, value: Option<V>, f: F)
907 where
908 K: AsRef<OsStr>,
909 V: AsRef<OsStr>,
910 F: FnOnce(),
911 {
912 let _guard = ENV_LOCK
913 .lock()
914 .unwrap_or_else(std::sync::PoisonError::into_inner);
915 let key = key.as_ref();
916 let prev = env::var_os(key);
917 match value {
918 Some(v) => unsafe { env::set_var(key, v.as_ref()) },
919 None => unsafe { env::remove_var(key) },
920 }
921 f();
922 match prev {
923 Some(v) => unsafe { env::set_var(key, v) },
924 None => unsafe { env::remove_var(key) },
925 }
926 }
927
928 pub fn with_vars<K, V, F>(pairs: impl IntoIterator<Item = (K, Option<V>)>, f: F)
930 where
931 K: AsRef<OsStr>,
932 V: AsRef<OsStr>,
933 F: FnOnce(),
934 {
935 let _guard = ENV_LOCK
936 .lock()
937 .unwrap_or_else(std::sync::PoisonError::into_inner);
938 let pairs: Vec<_> = pairs
939 .into_iter()
940 .map(|(k, v)| {
941 let key = k.as_ref().to_os_string();
942 let prev = env::var_os(&key);
943 let new_val = v.map(|v| v.as_ref().to_os_string());
944 (key, prev, new_val)
945 })
946 .collect();
947
948 for (key, _, new_val) in &pairs {
949 match new_val {
950 Some(v) => unsafe { env::set_var(key, v) },
951 None => unsafe { env::remove_var(key) },
952 }
953 }
954
955 f();
956
957 for (key, prev, _) in &pairs {
958 match prev {
959 Some(v) => unsafe { env::set_var(key, v) },
960 None => unsafe { env::remove_var(key) },
961 }
962 }
963 }
964}