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 disable_sandbox: bool,
188
189 pub cdp_fix_mode: CdpFixMode,
193
194 pub source_url: Option<String>,
201
202 pub pool: PoolConfig,
204
205 #[serde(with = "duration_secs")]
209 pub launch_timeout: Duration,
210
211 #[serde(with = "duration_secs")]
215 pub cdp_timeout: Duration,
216}
217
218impl Default for BrowserConfig {
219 fn default() -> Self {
220 Self {
221 chrome_path: std::env::var("STYGIAN_CHROME_PATH").ok().map(PathBuf::from),
222 args: vec![],
223 headless: env_bool("STYGIAN_HEADLESS", true),
224 user_data_dir: None,
225 window_size: Some((1920, 1080)),
226 devtools: false,
227 proxy: std::env::var("STYGIAN_PROXY").ok(),
228 proxy_bypass_list: std::env::var("STYGIAN_PROXY_BYPASS").ok(),
229 #[cfg(feature = "stealth")]
230 webrtc: WebRtcConfig::default(),
231 disable_sandbox: env_bool("STYGIAN_DISABLE_SANDBOX", is_containerized()),
232 stealth_level: StealthLevel::from_env(),
233 cdp_fix_mode: CdpFixMode::from_env(),
234 source_url: std::env::var("STYGIAN_SOURCE_URL").ok(),
235 pool: PoolConfig::default(),
236 launch_timeout: Duration::from_secs(env_u64("STYGIAN_LAUNCH_TIMEOUT_SECS", 10)),
237 cdp_timeout: Duration::from_secs(env_u64("STYGIAN_CDP_TIMEOUT_SECS", 30)),
238 }
239 }
240}
241
242impl BrowserConfig {
243 pub fn builder() -> BrowserConfigBuilder {
245 BrowserConfigBuilder {
246 config: Self::default(),
247 }
248 }
249
250 pub fn effective_args(&self) -> Vec<String> {
255 let mut args = vec![
256 "--disable-blink-features=AutomationControlled".to_string(),
257 "--disable-dev-shm-usage".to_string(),
258 "--disable-infobars".to_string(),
259 "--disable-background-timer-throttling".to_string(),
260 "--disable-backgrounding-occluded-windows".to_string(),
261 "--disable-renderer-backgrounding".to_string(),
262 ];
263
264 if self.disable_sandbox {
265 args.push("--no-sandbox".to_string());
266 }
267
268 if let Some(proxy) = &self.proxy {
269 args.push(format!("--proxy-server={proxy}"));
270 }
271
272 if let Some(bypass) = &self.proxy_bypass_list {
273 args.push(format!("--proxy-bypass-list={bypass}"));
274 }
275
276 #[cfg(feature = "stealth")]
277 args.extend(self.webrtc.chrome_args());
278
279 if let Some((w, h)) = self.window_size {
280 args.push(format!("--window-size={w},{h}"));
281 }
282
283 args.extend_from_slice(&self.args);
284 args
285 }
286
287 pub fn validate(&self) -> Result<(), Vec<String>> {
305 let mut errors: Vec<String> = Vec::new();
306
307 if self.pool.min_size > self.pool.max_size {
308 errors.push(format!(
309 "pool.min_size ({}) must be <= pool.max_size ({})",
310 self.pool.min_size, self.pool.max_size
311 ));
312 }
313 if self.pool.max_size == 0 {
314 errors.push("pool.max_size must be >= 1".to_string());
315 }
316 if self.launch_timeout.is_zero() {
317 errors.push("launch_timeout must be positive".to_string());
318 }
319 if self.cdp_timeout.is_zero() {
320 errors.push("cdp_timeout must be positive".to_string());
321 }
322 if let Some(proxy) = &self.proxy
323 && !proxy.starts_with("http://")
324 && !proxy.starts_with("https://")
325 && !proxy.starts_with("socks4://")
326 && !proxy.starts_with("socks5://")
327 {
328 errors.push(format!(
329 "proxy URL must start with http://, https://, socks4:// or socks5://; got: {proxy}"
330 ));
331 }
332
333 if errors.is_empty() {
334 Ok(())
335 } else {
336 Err(errors)
337 }
338 }
339
340 pub fn to_json(&self) -> Result<String, serde_json::Error> {
355 serde_json::to_string_pretty(self)
356 }
357
358 pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
379 serde_json::from_str(s)
380 }
381
382 pub fn from_json_file(path: impl AsRef<std::path::Path>) -> crate::error::Result<Self> {
396 use crate::error::BrowserError;
397 let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
398 BrowserError::ConfigError(format!(
399 "cannot read config file {}: {e}",
400 path.as_ref().display()
401 ))
402 })?;
403 serde_json::from_str(&content).map_err(|e| {
404 BrowserError::ConfigError(format!(
405 "invalid JSON in config file {}: {e}",
406 path.as_ref().display()
407 ))
408 })
409 }
410}
411
412pub struct BrowserConfigBuilder {
416 config: BrowserConfig,
417}
418
419impl BrowserConfigBuilder {
420 #[must_use]
422 pub fn chrome_path(mut self, path: PathBuf) -> Self {
423 self.config.chrome_path = Some(path);
424 self
425 }
426
427 #[must_use]
429 pub const fn headless(mut self, headless: bool) -> Self {
430 self.config.headless = headless;
431 self
432 }
433
434 #[must_use]
436 pub const fn window_size(mut self, width: u32, height: u32) -> Self {
437 self.config.window_size = Some((width, height));
438 self
439 }
440
441 #[must_use]
443 pub const fn devtools(mut self, enabled: bool) -> Self {
444 self.config.devtools = enabled;
445 self
446 }
447
448 #[must_use]
450 pub fn proxy(mut self, proxy: String) -> Self {
451 self.config.proxy = Some(proxy);
452 self
453 }
454
455 #[must_use]
467 pub fn proxy_bypass_list(mut self, bypass: String) -> Self {
468 self.config.proxy_bypass_list = Some(bypass);
469 self
470 }
471
472 #[cfg(feature = "stealth")]
484 #[must_use]
485 pub fn webrtc(mut self, webrtc: WebRtcConfig) -> Self {
486 self.config.webrtc = webrtc;
487 self
488 }
489
490 #[must_use]
492 pub fn arg(mut self, arg: String) -> Self {
493 self.config.args.push(arg);
494 self
495 }
496
497 #[must_use]
499 pub const fn stealth_level(mut self, level: StealthLevel) -> Self {
500 self.config.stealth_level = level;
501 self
502 }
503
504 #[must_use]
518 pub const fn disable_sandbox(mut self, disable: bool) -> Self {
519 self.config.disable_sandbox = disable;
520 self
521 }
522
523 #[must_use]
536 pub const fn cdp_fix_mode(mut self, mode: CdpFixMode) -> Self {
537 self.config.cdp_fix_mode = mode;
538 self
539 }
540
541 #[must_use]
554 pub fn source_url(mut self, url: Option<String>) -> Self {
555 self.config.source_url = url;
556 self
557 }
558
559 #[must_use]
561 pub const fn pool(mut self, pool: PoolConfig) -> Self {
562 self.config.pool = pool;
563 self
564 }
565
566 pub fn build(self) -> BrowserConfig {
568 self.config
569 }
570}
571
572mod duration_secs {
576 use serde::{Deserialize, Deserializer, Serialize, Serializer};
577 use std::time::Duration;
578
579 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
580 d.as_secs().serialize(s)
581 }
582
583 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Duration, D::Error> {
584 Ok(Duration::from_secs(u64::deserialize(d)?))
585 }
586}
587
588fn env_bool(key: &str, default: bool) -> bool {
591 std::env::var(key)
592 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "0" | "no"))
593 .unwrap_or(default)
594}
595
596fn is_containerized() -> bool {
607 #[cfg(target_os = "linux")]
608 {
609 if std::path::Path::new("/.dockerenv").exists() {
610 return true;
611 }
612 if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
613 if cgroup.contains("docker") || cgroup.contains("kubepods") {
614 return true;
615 }
616 }
617 false
618 }
619 #[cfg(not(target_os = "linux"))]
620 {
621 false
622 }
623}
624
625fn env_u64(key: &str, default: u64) -> u64 {
626 std::env::var(key)
627 .ok()
628 .and_then(|v| v.parse().ok())
629 .unwrap_or(default)
630}
631
632fn env_usize(key: &str, default: usize) -> usize {
633 std::env::var(key)
634 .ok()
635 .and_then(|v| v.parse().ok())
636 .unwrap_or(default)
637}
638
639#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn default_config_is_headless() {
647 let cfg = BrowserConfig::default();
648 assert!(cfg.headless);
649 }
650
651 #[test]
652 fn builder_roundtrip() {
653 let cfg = BrowserConfig::builder()
654 .headless(false)
655 .window_size(1280, 720)
656 .stealth_level(StealthLevel::Basic)
657 .build();
658
659 assert!(!cfg.headless);
660 assert_eq!(cfg.window_size, Some((1280, 720)));
661 assert_eq!(cfg.stealth_level, StealthLevel::Basic);
662 }
663
664 #[test]
665 fn effective_args_include_anti_detection_flag() {
666 let cfg = BrowserConfig::default();
667 let args = cfg.effective_args();
668 assert!(args.iter().any(|a| a.contains("AutomationControlled")));
669 }
670
671 #[test]
672 fn no_sandbox_only_when_explicitly_enabled() {
673 let with_sandbox_disabled = BrowserConfig::builder().disable_sandbox(true).build();
674 assert!(
675 with_sandbox_disabled
676 .effective_args()
677 .iter()
678 .any(|a| a == "--no-sandbox")
679 );
680
681 let with_sandbox_enabled = BrowserConfig::builder().disable_sandbox(false).build();
682 assert!(
683 !with_sandbox_enabled
684 .effective_args()
685 .iter()
686 .any(|a| a == "--no-sandbox")
687 );
688 }
689
690 #[test]
691 fn pool_config_defaults() {
692 let p = PoolConfig::default();
693 assert_eq!(p.min_size, 2);
694 assert_eq!(p.max_size, 10);
695 }
696
697 #[test]
698 fn stealth_level_none_not_active() {
699 assert!(!StealthLevel::None.is_active());
700 assert!(StealthLevel::Basic.is_active());
701 assert!(StealthLevel::Advanced.is_active());
702 }
703
704 #[test]
705 fn config_serialization() -> Result<(), Box<dyn std::error::Error>> {
706 let cfg = BrowserConfig::default();
707 let json = serde_json::to_string(&cfg)?;
708 let back: BrowserConfig = serde_json::from_str(&json)?;
709 assert_eq!(back.headless, cfg.headless);
710 assert_eq!(back.stealth_level, cfg.stealth_level);
711 Ok(())
712 }
713
714 #[test]
715 fn validate_default_config_is_valid() {
716 let cfg = BrowserConfig::default();
717 assert!(cfg.validate().is_ok(), "default config must be valid");
718 }
719
720 #[test]
721 fn validate_detects_pool_size_inversion() {
722 let cfg = BrowserConfig {
723 pool: PoolConfig {
724 min_size: 10,
725 max_size: 5,
726 ..PoolConfig::default()
727 },
728 ..BrowserConfig::default()
729 };
730 let result = cfg.validate();
731 assert!(result.is_err());
732 if let Err(errors) = result {
733 assert!(errors.iter().any(|e| e.contains("min_size")));
734 }
735 }
736
737 #[test]
738 fn validate_detects_zero_max_pool() {
739 let cfg = BrowserConfig {
740 pool: PoolConfig {
741 max_size: 0,
742 ..PoolConfig::default()
743 },
744 ..BrowserConfig::default()
745 };
746 let result = cfg.validate();
747 assert!(result.is_err());
748 if let Err(errors) = result {
749 assert!(errors.iter().any(|e| e.contains("max_size")));
750 }
751 }
752
753 #[test]
754 fn validate_detects_zero_timeouts() {
755 let cfg = BrowserConfig {
756 launch_timeout: std::time::Duration::ZERO,
757 cdp_timeout: std::time::Duration::ZERO,
758 ..BrowserConfig::default()
759 };
760 let result = cfg.validate();
761 assert!(result.is_err());
762 if let Err(errors) = result {
763 assert_eq!(errors.len(), 2);
764 }
765 }
766
767 #[test]
768 fn validate_detects_bad_proxy_scheme() {
769 let cfg = BrowserConfig {
770 proxy: Some("ftp://bad.proxy:1234".to_string()),
771 ..BrowserConfig::default()
772 };
773 let result = cfg.validate();
774 assert!(result.is_err());
775 if let Err(errors) = result {
776 assert!(errors.iter().any(|e| e.contains("proxy URL")));
777 }
778 }
779
780 #[test]
781 fn validate_accepts_valid_proxy() {
782 let cfg = BrowserConfig {
783 proxy: Some("socks5://user:pass@127.0.0.1:1080".to_string()),
784 ..BrowserConfig::default()
785 };
786 assert!(cfg.validate().is_ok());
787 }
788
789 #[test]
790 fn to_json_and_from_json_str_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
791 let cfg = BrowserConfig::builder()
792 .headless(false)
793 .stealth_level(StealthLevel::Basic)
794 .build();
795 let json = cfg.to_json()?;
796 assert!(json.contains("headless"));
797 let back = BrowserConfig::from_json_str(&json)?;
798 assert!(!back.headless);
799 assert_eq!(back.stealth_level, StealthLevel::Basic);
800 Ok(())
801 }
802
803 #[test]
804 fn from_json_str_error_on_invalid_json() {
805 let err = BrowserConfig::from_json_str("not json at all");
806 assert!(err.is_err());
807 }
808
809 #[test]
810 fn builder_cdp_fix_mode_and_source_url() {
811 use crate::cdp_protection::CdpFixMode;
812 let cfg = BrowserConfig::builder()
813 .cdp_fix_mode(CdpFixMode::IsolatedWorld)
814 .source_url(Some("stealth.js".to_string()))
815 .build();
816 assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
817 assert_eq!(cfg.source_url.as_deref(), Some("stealth.js"));
818 }
819
820 #[test]
821 fn builder_source_url_none_disables_sourceurl() {
822 let cfg = BrowserConfig::builder().source_url(None).build();
823 assert!(cfg.source_url.is_none());
824 }
825
826 #[test]
834 fn stealth_level_from_env_none() {
835 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("none"), || {
838 let level = StealthLevel::from_env();
839 assert_eq!(level, StealthLevel::None);
840 });
841 }
842
843 #[test]
844 fn stealth_level_from_env_basic() {
845 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("basic"), || {
846 assert_eq!(StealthLevel::from_env(), StealthLevel::Basic);
847 });
848 }
849
850 #[test]
851 fn stealth_level_from_env_advanced_is_default() {
852 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("anything_else"), || {
853 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
854 });
855 }
856
857 #[test]
858 fn stealth_level_from_env_missing_defaults_to_advanced() {
859 temp_env::with_var("STYGIAN_STEALTH_LEVEL", None::<&str>, || {
861 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
862 });
863 }
864
865 #[test]
866 fn cdp_fix_mode_from_env_variants() {
867 use crate::cdp_protection::CdpFixMode;
868 let cases = [
869 ("add_binding", CdpFixMode::AddBinding),
870 ("isolatedworld", CdpFixMode::IsolatedWorld),
871 ("enable_disable", CdpFixMode::EnableDisable),
872 ("none", CdpFixMode::None),
873 ("unknown_value", CdpFixMode::AddBinding), ];
875 for (val, expected) in cases {
876 temp_env::with_var("STYGIAN_CDP_FIX_MODE", Some(val), || {
877 assert_eq!(
878 CdpFixMode::from_env(),
879 expected,
880 "STYGIAN_CDP_FIX_MODE={val}"
881 );
882 });
883 }
884 }
885
886 #[test]
887 fn pool_config_from_env_min_max() {
888 temp_env::with_vars(
889 [
890 ("STYGIAN_POOL_MIN", Some("3")),
891 ("STYGIAN_POOL_MAX", Some("15")),
892 ],
893 || {
894 let p = PoolConfig::default();
895 assert_eq!(p.min_size, 3);
896 assert_eq!(p.max_size, 15);
897 },
898 );
899 }
900
901 #[test]
902 fn headless_from_env_false() {
903 temp_env::with_var("STYGIAN_HEADLESS", Some("false"), || {
904 assert!(!env_bool("STYGIAN_HEADLESS", true));
906 });
907 }
908
909 #[test]
910 fn headless_from_env_zero_means_false() {
911 temp_env::with_var("STYGIAN_HEADLESS", Some("0"), || {
912 assert!(!env_bool("STYGIAN_HEADLESS", true));
913 });
914 }
915
916 #[test]
917 fn headless_from_env_no_means_false() {
918 temp_env::with_var("STYGIAN_HEADLESS", Some("no"), || {
919 assert!(!env_bool("STYGIAN_HEADLESS", true));
920 });
921 }
922
923 #[test]
924 fn validate_accepts_socks4_proxy() {
925 let cfg = BrowserConfig {
926 proxy: Some("socks4://127.0.0.1:1080".to_string()),
927 ..BrowserConfig::default()
928 };
929 assert!(cfg.validate().is_ok());
930 }
931
932 #[test]
933 fn validate_multiple_errors_returned_together() {
934 let cfg = BrowserConfig {
935 pool: PoolConfig {
936 min_size: 10,
937 max_size: 5,
938 ..PoolConfig::default()
939 },
940 launch_timeout: std::time::Duration::ZERO,
941 proxy: Some("ftp://bad".to_string()),
942 ..BrowserConfig::default()
943 };
944 let result = cfg.validate();
945 assert!(result.is_err());
946 if let Err(errors) = result {
947 assert!(errors.len() >= 3, "expected ≥3 errors, got: {errors:?}");
948 }
949 }
950
951 #[test]
952 fn json_file_error_on_missing_file() {
953 let result = BrowserConfig::from_json_file("/nonexistent/path/config.json");
954 assert!(result.is_err());
955 if let Err(e) = result {
956 let err_str = e.to_string();
957 assert!(err_str.contains("cannot read config file") || err_str.contains("config"));
958 }
959 }
960
961 #[test]
962 fn json_roundtrip_preserves_cdp_fix_mode() -> Result<(), Box<dyn std::error::Error>> {
963 use crate::cdp_protection::CdpFixMode;
964 let cfg = BrowserConfig::builder()
965 .cdp_fix_mode(CdpFixMode::EnableDisable)
966 .build();
967 let json = cfg.to_json()?;
968 let back = BrowserConfig::from_json_str(&json)?;
969 assert_eq!(back.cdp_fix_mode, CdpFixMode::EnableDisable);
970 Ok(())
971 }
972}
973
974#[cfg(test)]
980mod temp_env {
981 use std::env;
982 use std::ffi::OsStr;
983 use std::sync::Mutex;
984
985 static ENV_LOCK: Mutex<()> = Mutex::new(());
987
988 pub fn with_var<K, V, F>(key: K, value: Option<V>, f: F)
991 where
992 K: AsRef<OsStr>,
993 V: AsRef<OsStr>,
994 F: FnOnce(),
995 {
996 let _guard = ENV_LOCK
997 .lock()
998 .unwrap_or_else(std::sync::PoisonError::into_inner);
999 let key = key.as_ref();
1000 let prev = env::var_os(key);
1001 match value {
1002 Some(v) => unsafe { env::set_var(key, v.as_ref()) },
1003 None => unsafe { env::remove_var(key) },
1004 }
1005 f();
1006 match prev {
1007 Some(v) => unsafe { env::set_var(key, v) },
1008 None => unsafe { env::remove_var(key) },
1009 }
1010 }
1011
1012 pub fn with_vars<K, V, F>(pairs: impl IntoIterator<Item = (K, Option<V>)>, f: F)
1014 where
1015 K: AsRef<OsStr>,
1016 V: AsRef<OsStr>,
1017 F: FnOnce(),
1018 {
1019 let _guard = ENV_LOCK
1020 .lock()
1021 .unwrap_or_else(std::sync::PoisonError::into_inner);
1022 let pairs: Vec<_> = pairs
1023 .into_iter()
1024 .map(|(k, v)| {
1025 let key = k.as_ref().to_os_string();
1026 let prev = env::var_os(&key);
1027 let new_val = v.map(|v| v.as_ref().to_os_string());
1028 (key, prev, new_val)
1029 })
1030 .collect();
1031
1032 for (key, _, new_val) in &pairs {
1033 match new_val {
1034 Some(v) => unsafe { env::set_var(key, v) },
1035 None => unsafe { env::remove_var(key) },
1036 }
1037 }
1038
1039 f();
1040
1041 for (key, prev, _) in &pairs {
1042 match prev {
1043 Some(v) => unsafe { env::set_var(key, v) },
1044 None => unsafe { env::remove_var(key) },
1045 }
1046 }
1047 }
1048}