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
596#[allow(clippy::missing_const_for_fn)] fn is_containerized() -> bool {
608 #[cfg(target_os = "linux")]
609 {
610 if std::path::Path::new("/.dockerenv").exists() {
611 return true;
612 }
613 if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
614 && (cgroup.contains("docker") || cgroup.contains("kubepods"))
615 {
616 return true;
617 }
618 false
619 }
620 #[cfg(not(target_os = "linux"))]
621 {
622 false
623 }
624}
625
626fn env_u64(key: &str, default: u64) -> u64 {
627 std::env::var(key)
628 .ok()
629 .and_then(|v| v.parse().ok())
630 .unwrap_or(default)
631}
632
633fn env_usize(key: &str, default: usize) -> usize {
634 std::env::var(key)
635 .ok()
636 .and_then(|v| v.parse().ok())
637 .unwrap_or(default)
638}
639
640#[cfg(test)]
643mod tests {
644 use super::*;
645
646 #[test]
647 fn default_config_is_headless() {
648 let cfg = BrowserConfig::default();
649 assert!(cfg.headless);
650 }
651
652 #[test]
653 fn builder_roundtrip() {
654 let cfg = BrowserConfig::builder()
655 .headless(false)
656 .window_size(1280, 720)
657 .stealth_level(StealthLevel::Basic)
658 .build();
659
660 assert!(!cfg.headless);
661 assert_eq!(cfg.window_size, Some((1280, 720)));
662 assert_eq!(cfg.stealth_level, StealthLevel::Basic);
663 }
664
665 #[test]
666 fn effective_args_include_anti_detection_flag() {
667 let cfg = BrowserConfig::default();
668 let args = cfg.effective_args();
669 assert!(args.iter().any(|a| a.contains("AutomationControlled")));
670 }
671
672 #[test]
673 fn no_sandbox_only_when_explicitly_enabled() {
674 let with_sandbox_disabled = BrowserConfig::builder().disable_sandbox(true).build();
675 assert!(
676 with_sandbox_disabled
677 .effective_args()
678 .iter()
679 .any(|a| a == "--no-sandbox")
680 );
681
682 let with_sandbox_enabled = BrowserConfig::builder().disable_sandbox(false).build();
683 assert!(
684 !with_sandbox_enabled
685 .effective_args()
686 .iter()
687 .any(|a| a == "--no-sandbox")
688 );
689 }
690
691 #[test]
692 fn pool_config_defaults() {
693 let p = PoolConfig::default();
694 assert_eq!(p.min_size, 2);
695 assert_eq!(p.max_size, 10);
696 }
697
698 #[test]
699 fn stealth_level_none_not_active() {
700 assert!(!StealthLevel::None.is_active());
701 assert!(StealthLevel::Basic.is_active());
702 assert!(StealthLevel::Advanced.is_active());
703 }
704
705 #[test]
706 fn config_serialization() -> Result<(), Box<dyn std::error::Error>> {
707 let cfg = BrowserConfig::default();
708 let json = serde_json::to_string(&cfg)?;
709 let back: BrowserConfig = serde_json::from_str(&json)?;
710 assert_eq!(back.headless, cfg.headless);
711 assert_eq!(back.stealth_level, cfg.stealth_level);
712 Ok(())
713 }
714
715 #[test]
716 fn validate_default_config_is_valid() {
717 let cfg = BrowserConfig::default();
718 assert!(cfg.validate().is_ok(), "default config must be valid");
719 }
720
721 #[test]
722 fn validate_detects_pool_size_inversion() {
723 let cfg = BrowserConfig {
724 pool: PoolConfig {
725 min_size: 10,
726 max_size: 5,
727 ..PoolConfig::default()
728 },
729 ..BrowserConfig::default()
730 };
731 let result = cfg.validate();
732 assert!(result.is_err());
733 if let Err(errors) = result {
734 assert!(errors.iter().any(|e| e.contains("min_size")));
735 }
736 }
737
738 #[test]
739 fn validate_detects_zero_max_pool() {
740 let cfg = BrowserConfig {
741 pool: PoolConfig {
742 max_size: 0,
743 ..PoolConfig::default()
744 },
745 ..BrowserConfig::default()
746 };
747 let result = cfg.validate();
748 assert!(result.is_err());
749 if let Err(errors) = result {
750 assert!(errors.iter().any(|e| e.contains("max_size")));
751 }
752 }
753
754 #[test]
755 fn validate_detects_zero_timeouts() {
756 let cfg = BrowserConfig {
757 launch_timeout: std::time::Duration::ZERO,
758 cdp_timeout: std::time::Duration::ZERO,
759 ..BrowserConfig::default()
760 };
761 let result = cfg.validate();
762 assert!(result.is_err());
763 if let Err(errors) = result {
764 assert_eq!(errors.len(), 2);
765 }
766 }
767
768 #[test]
769 fn validate_detects_bad_proxy_scheme() {
770 let cfg = BrowserConfig {
771 proxy: Some("ftp://bad.proxy:1234".to_string()),
772 ..BrowserConfig::default()
773 };
774 let result = cfg.validate();
775 assert!(result.is_err());
776 if let Err(errors) = result {
777 assert!(errors.iter().any(|e| e.contains("proxy URL")));
778 }
779 }
780
781 #[test]
782 fn validate_accepts_valid_proxy() {
783 let cfg = BrowserConfig {
784 proxy: Some("socks5://user:pass@127.0.0.1:1080".to_string()),
785 ..BrowserConfig::default()
786 };
787 assert!(cfg.validate().is_ok());
788 }
789
790 #[test]
791 fn to_json_and_from_json_str_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
792 let cfg = BrowserConfig::builder()
793 .headless(false)
794 .stealth_level(StealthLevel::Basic)
795 .build();
796 let json = cfg.to_json()?;
797 assert!(json.contains("headless"));
798 let back = BrowserConfig::from_json_str(&json)?;
799 assert!(!back.headless);
800 assert_eq!(back.stealth_level, StealthLevel::Basic);
801 Ok(())
802 }
803
804 #[test]
805 fn from_json_str_error_on_invalid_json() {
806 let err = BrowserConfig::from_json_str("not json at all");
807 assert!(err.is_err());
808 }
809
810 #[test]
811 fn builder_cdp_fix_mode_and_source_url() {
812 use crate::cdp_protection::CdpFixMode;
813 let cfg = BrowserConfig::builder()
814 .cdp_fix_mode(CdpFixMode::IsolatedWorld)
815 .source_url(Some("stealth.js".to_string()))
816 .build();
817 assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
818 assert_eq!(cfg.source_url.as_deref(), Some("stealth.js"));
819 }
820
821 #[test]
822 fn builder_source_url_none_disables_sourceurl() {
823 let cfg = BrowserConfig::builder().source_url(None).build();
824 assert!(cfg.source_url.is_none());
825 }
826
827 #[test]
835 fn stealth_level_from_env_none() {
836 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("none"), || {
839 let level = StealthLevel::from_env();
840 assert_eq!(level, StealthLevel::None);
841 });
842 }
843
844 #[test]
845 fn stealth_level_from_env_basic() {
846 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("basic"), || {
847 assert_eq!(StealthLevel::from_env(), StealthLevel::Basic);
848 });
849 }
850
851 #[test]
852 fn stealth_level_from_env_advanced_is_default() {
853 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("anything_else"), || {
854 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
855 });
856 }
857
858 #[test]
859 fn stealth_level_from_env_missing_defaults_to_advanced() {
860 temp_env::with_var("STYGIAN_STEALTH_LEVEL", None::<&str>, || {
862 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
863 });
864 }
865
866 #[test]
867 fn cdp_fix_mode_from_env_variants() {
868 use crate::cdp_protection::CdpFixMode;
869 let cases = [
870 ("add_binding", CdpFixMode::AddBinding),
871 ("isolatedworld", CdpFixMode::IsolatedWorld),
872 ("enable_disable", CdpFixMode::EnableDisable),
873 ("none", CdpFixMode::None),
874 ("unknown_value", CdpFixMode::AddBinding), ];
876 for (val, expected) in cases {
877 temp_env::with_var("STYGIAN_CDP_FIX_MODE", Some(val), || {
878 assert_eq!(
879 CdpFixMode::from_env(),
880 expected,
881 "STYGIAN_CDP_FIX_MODE={val}"
882 );
883 });
884 }
885 }
886
887 #[test]
888 fn pool_config_from_env_min_max() {
889 temp_env::with_vars(
890 [
891 ("STYGIAN_POOL_MIN", Some("3")),
892 ("STYGIAN_POOL_MAX", Some("15")),
893 ],
894 || {
895 let p = PoolConfig::default();
896 assert_eq!(p.min_size, 3);
897 assert_eq!(p.max_size, 15);
898 },
899 );
900 }
901
902 #[test]
903 fn headless_from_env_false() {
904 temp_env::with_var("STYGIAN_HEADLESS", Some("false"), || {
905 assert!(!env_bool("STYGIAN_HEADLESS", true));
907 });
908 }
909
910 #[test]
911 fn headless_from_env_zero_means_false() {
912 temp_env::with_var("STYGIAN_HEADLESS", Some("0"), || {
913 assert!(!env_bool("STYGIAN_HEADLESS", true));
914 });
915 }
916
917 #[test]
918 fn headless_from_env_no_means_false() {
919 temp_env::with_var("STYGIAN_HEADLESS", Some("no"), || {
920 assert!(!env_bool("STYGIAN_HEADLESS", true));
921 });
922 }
923
924 #[test]
925 fn validate_accepts_socks4_proxy() {
926 let cfg = BrowserConfig {
927 proxy: Some("socks4://127.0.0.1:1080".to_string()),
928 ..BrowserConfig::default()
929 };
930 assert!(cfg.validate().is_ok());
931 }
932
933 #[test]
934 fn validate_multiple_errors_returned_together() {
935 let cfg = BrowserConfig {
936 pool: PoolConfig {
937 min_size: 10,
938 max_size: 5,
939 ..PoolConfig::default()
940 },
941 launch_timeout: std::time::Duration::ZERO,
942 proxy: Some("ftp://bad".to_string()),
943 ..BrowserConfig::default()
944 };
945 let result = cfg.validate();
946 assert!(result.is_err());
947 if let Err(errors) = result {
948 assert!(errors.len() >= 3, "expected ≥3 errors, got: {errors:?}");
949 }
950 }
951
952 #[test]
953 fn json_file_error_on_missing_file() {
954 let result = BrowserConfig::from_json_file("/nonexistent/path/config.json");
955 assert!(result.is_err());
956 if let Err(e) = result {
957 let err_str = e.to_string();
958 assert!(err_str.contains("cannot read config file") || err_str.contains("config"));
959 }
960 }
961
962 #[test]
963 fn json_roundtrip_preserves_cdp_fix_mode() -> Result<(), Box<dyn std::error::Error>> {
964 use crate::cdp_protection::CdpFixMode;
965 let cfg = BrowserConfig::builder()
966 .cdp_fix_mode(CdpFixMode::EnableDisable)
967 .build();
968 let json = cfg.to_json()?;
969 let back = BrowserConfig::from_json_str(&json)?;
970 assert_eq!(back.cdp_fix_mode, CdpFixMode::EnableDisable);
971 Ok(())
972 }
973}
974
975#[cfg(test)]
981#[allow(unsafe_code)] mod temp_env {
983 use std::env;
984 use std::ffi::OsStr;
985 use std::sync::Mutex;
986
987 static ENV_LOCK: Mutex<()> = Mutex::new(());
989
990 pub fn with_var<K, V, F>(key: K, value: Option<V>, f: F)
993 where
994 K: AsRef<OsStr>,
995 V: AsRef<OsStr>,
996 F: FnOnce(),
997 {
998 let _guard = ENV_LOCK
999 .lock()
1000 .unwrap_or_else(std::sync::PoisonError::into_inner);
1001 let key = key.as_ref();
1002 let prev = env::var_os(key);
1003 match value {
1004 Some(v) => unsafe { env::set_var(key, v.as_ref()) },
1005 None => unsafe { env::remove_var(key) },
1006 }
1007 f();
1008 match prev {
1009 Some(v) => unsafe { env::set_var(key, v) },
1010 None => unsafe { env::remove_var(key) },
1011 }
1012 }
1013
1014 pub fn with_vars<K, V, F>(pairs: impl IntoIterator<Item = (K, Option<V>)>, f: F)
1016 where
1017 K: AsRef<OsStr>,
1018 V: AsRef<OsStr>,
1019 F: FnOnce(),
1020 {
1021 let _guard = ENV_LOCK
1022 .lock()
1023 .unwrap_or_else(std::sync::PoisonError::into_inner);
1024 let pairs: Vec<_> = pairs
1025 .into_iter()
1026 .map(|(k, v)| {
1027 let key = k.as_ref().to_os_string();
1028 let prev = env::var_os(&key);
1029 let new_val = v.map(|v| v.as_ref().to_os_string());
1030 (key, prev, new_val)
1031 })
1032 .collect();
1033
1034 for (key, _, new_val) in &pairs {
1035 match new_val {
1036 Some(v) => unsafe { env::set_var(key, v) },
1037 None => unsafe { env::remove_var(key) },
1038 }
1039 }
1040
1041 f();
1042
1043 for (key, prev, _) in &pairs {
1044 match prev {
1045 Some(v) => unsafe { env::set_var(key, v) },
1046 None => unsafe { env::remove_var(key) },
1047 }
1048 }
1049 }
1050}