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]
443 pub fn user_data_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
444 self.config.user_data_dir = Some(path.into());
445 self
446 }
447
448 #[must_use]
450 pub const fn headless(mut self, headless: bool) -> Self {
451 self.config.headless = headless;
452 self
453 }
454
455 #[must_use]
457 pub const fn window_size(mut self, width: u32, height: u32) -> Self {
458 self.config.window_size = Some((width, height));
459 self
460 }
461
462 #[must_use]
464 pub const fn devtools(mut self, enabled: bool) -> Self {
465 self.config.devtools = enabled;
466 self
467 }
468
469 #[must_use]
471 pub fn proxy(mut self, proxy: String) -> Self {
472 self.config.proxy = Some(proxy);
473 self
474 }
475
476 #[must_use]
488 pub fn proxy_bypass_list(mut self, bypass: String) -> Self {
489 self.config.proxy_bypass_list = Some(bypass);
490 self
491 }
492
493 #[cfg(feature = "stealth")]
505 #[must_use]
506 pub fn webrtc(mut self, webrtc: WebRtcConfig) -> Self {
507 self.config.webrtc = webrtc;
508 self
509 }
510
511 #[must_use]
513 pub fn arg(mut self, arg: String) -> Self {
514 self.config.args.push(arg);
515 self
516 }
517
518 #[must_use]
520 pub const fn stealth_level(mut self, level: StealthLevel) -> Self {
521 self.config.stealth_level = level;
522 self
523 }
524
525 #[must_use]
539 pub const fn disable_sandbox(mut self, disable: bool) -> Self {
540 self.config.disable_sandbox = disable;
541 self
542 }
543
544 #[must_use]
557 pub const fn cdp_fix_mode(mut self, mode: CdpFixMode) -> Self {
558 self.config.cdp_fix_mode = mode;
559 self
560 }
561
562 #[must_use]
575 pub fn source_url(mut self, url: Option<String>) -> Self {
576 self.config.source_url = url;
577 self
578 }
579
580 #[must_use]
582 pub const fn pool(mut self, pool: PoolConfig) -> Self {
583 self.config.pool = pool;
584 self
585 }
586
587 pub fn build(self) -> BrowserConfig {
589 self.config
590 }
591}
592
593mod duration_secs {
597 use serde::{Deserialize, Deserializer, Serialize, Serializer};
598 use std::time::Duration;
599
600 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
601 d.as_secs().serialize(s)
602 }
603
604 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Duration, D::Error> {
605 Ok(Duration::from_secs(u64::deserialize(d)?))
606 }
607}
608
609fn env_bool(key: &str, default: bool) -> bool {
612 std::env::var(key)
613 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "0" | "no"))
614 .unwrap_or(default)
615}
616
617#[allow(clippy::missing_const_for_fn)] fn is_containerized() -> bool {
629 #[cfg(target_os = "linux")]
630 {
631 if std::path::Path::new("/.dockerenv").exists() {
632 return true;
633 }
634 if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
635 && (cgroup.contains("docker") || cgroup.contains("kubepods"))
636 {
637 return true;
638 }
639 false
640 }
641 #[cfg(not(target_os = "linux"))]
642 {
643 false
644 }
645}
646
647fn env_u64(key: &str, default: u64) -> u64 {
648 std::env::var(key)
649 .ok()
650 .and_then(|v| v.parse().ok())
651 .unwrap_or(default)
652}
653
654fn env_usize(key: &str, default: usize) -> usize {
655 std::env::var(key)
656 .ok()
657 .and_then(|v| v.parse().ok())
658 .unwrap_or(default)
659}
660
661#[cfg(test)]
664mod tests {
665 use super::*;
666
667 #[test]
668 fn default_config_is_headless() {
669 let cfg = BrowserConfig::default();
670 assert!(cfg.headless);
671 }
672
673 #[test]
674 fn builder_roundtrip() {
675 let cfg = BrowserConfig::builder()
676 .headless(false)
677 .window_size(1280, 720)
678 .stealth_level(StealthLevel::Basic)
679 .build();
680
681 assert!(!cfg.headless);
682 assert_eq!(cfg.window_size, Some((1280, 720)));
683 assert_eq!(cfg.stealth_level, StealthLevel::Basic);
684 }
685
686 #[test]
687 fn effective_args_include_anti_detection_flag() {
688 let cfg = BrowserConfig::default();
689 let args = cfg.effective_args();
690 assert!(args.iter().any(|a| a.contains("AutomationControlled")));
691 }
692
693 #[test]
694 fn no_sandbox_only_when_explicitly_enabled() {
695 let with_sandbox_disabled = BrowserConfig::builder().disable_sandbox(true).build();
696 assert!(
697 with_sandbox_disabled
698 .effective_args()
699 .iter()
700 .any(|a| a == "--no-sandbox")
701 );
702
703 let with_sandbox_enabled = BrowserConfig::builder().disable_sandbox(false).build();
704 assert!(
705 !with_sandbox_enabled
706 .effective_args()
707 .iter()
708 .any(|a| a == "--no-sandbox")
709 );
710 }
711
712 #[test]
713 fn pool_config_defaults() {
714 let p = PoolConfig::default();
715 assert_eq!(p.min_size, 2);
716 assert_eq!(p.max_size, 10);
717 }
718
719 #[test]
720 fn stealth_level_none_not_active() {
721 assert!(!StealthLevel::None.is_active());
722 assert!(StealthLevel::Basic.is_active());
723 assert!(StealthLevel::Advanced.is_active());
724 }
725
726 #[test]
727 fn config_serialization() -> Result<(), Box<dyn std::error::Error>> {
728 let cfg = BrowserConfig::default();
729 let json = serde_json::to_string(&cfg)?;
730 let back: BrowserConfig = serde_json::from_str(&json)?;
731 assert_eq!(back.headless, cfg.headless);
732 assert_eq!(back.stealth_level, cfg.stealth_level);
733 Ok(())
734 }
735
736 #[test]
737 fn validate_default_config_is_valid() {
738 let cfg = BrowserConfig::default();
739 assert!(cfg.validate().is_ok(), "default config must be valid");
740 }
741
742 #[test]
743 fn validate_detects_pool_size_inversion() {
744 let cfg = BrowserConfig {
745 pool: PoolConfig {
746 min_size: 10,
747 max_size: 5,
748 ..PoolConfig::default()
749 },
750 ..BrowserConfig::default()
751 };
752 let result = cfg.validate();
753 assert!(result.is_err());
754 if let Err(errors) = result {
755 assert!(errors.iter().any(|e| e.contains("min_size")));
756 }
757 }
758
759 #[test]
760 fn validate_detects_zero_max_pool() {
761 let cfg = BrowserConfig {
762 pool: PoolConfig {
763 max_size: 0,
764 ..PoolConfig::default()
765 },
766 ..BrowserConfig::default()
767 };
768 let result = cfg.validate();
769 assert!(result.is_err());
770 if let Err(errors) = result {
771 assert!(errors.iter().any(|e| e.contains("max_size")));
772 }
773 }
774
775 #[test]
776 fn validate_detects_zero_timeouts() {
777 let cfg = BrowserConfig {
778 launch_timeout: std::time::Duration::ZERO,
779 cdp_timeout: std::time::Duration::ZERO,
780 ..BrowserConfig::default()
781 };
782 let result = cfg.validate();
783 assert!(result.is_err());
784 if let Err(errors) = result {
785 assert_eq!(errors.len(), 2);
786 }
787 }
788
789 #[test]
790 fn validate_detects_bad_proxy_scheme() {
791 let cfg = BrowserConfig {
792 proxy: Some("ftp://bad.proxy:1234".to_string()),
793 ..BrowserConfig::default()
794 };
795 let result = cfg.validate();
796 assert!(result.is_err());
797 if let Err(errors) = result {
798 assert!(errors.iter().any(|e| e.contains("proxy URL")));
799 }
800 }
801
802 #[test]
803 fn validate_accepts_valid_proxy() {
804 let cfg = BrowserConfig {
805 proxy: Some("socks5://user:pass@127.0.0.1:1080".to_string()),
806 ..BrowserConfig::default()
807 };
808 assert!(cfg.validate().is_ok());
809 }
810
811 #[test]
812 fn to_json_and_from_json_str_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
813 let cfg = BrowserConfig::builder()
814 .headless(false)
815 .stealth_level(StealthLevel::Basic)
816 .build();
817 let json = cfg.to_json()?;
818 assert!(json.contains("headless"));
819 let back = BrowserConfig::from_json_str(&json)?;
820 assert!(!back.headless);
821 assert_eq!(back.stealth_level, StealthLevel::Basic);
822 Ok(())
823 }
824
825 #[test]
826 fn from_json_str_error_on_invalid_json() {
827 let err = BrowserConfig::from_json_str("not json at all");
828 assert!(err.is_err());
829 }
830
831 #[test]
832 fn builder_cdp_fix_mode_and_source_url() {
833 use crate::cdp_protection::CdpFixMode;
834 let cfg = BrowserConfig::builder()
835 .cdp_fix_mode(CdpFixMode::IsolatedWorld)
836 .source_url(Some("stealth.js".to_string()))
837 .build();
838 assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
839 assert_eq!(cfg.source_url.as_deref(), Some("stealth.js"));
840 }
841
842 #[test]
843 fn builder_source_url_none_disables_sourceurl() {
844 let cfg = BrowserConfig::builder().source_url(None).build();
845 assert!(cfg.source_url.is_none());
846 }
847
848 #[test]
856 fn stealth_level_from_env_none() {
857 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("none"), || {
860 let level = StealthLevel::from_env();
861 assert_eq!(level, StealthLevel::None);
862 });
863 }
864
865 #[test]
866 fn stealth_level_from_env_basic() {
867 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("basic"), || {
868 assert_eq!(StealthLevel::from_env(), StealthLevel::Basic);
869 });
870 }
871
872 #[test]
873 fn stealth_level_from_env_advanced_is_default() {
874 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("anything_else"), || {
875 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
876 });
877 }
878
879 #[test]
880 fn stealth_level_from_env_missing_defaults_to_advanced() {
881 temp_env::with_var("STYGIAN_STEALTH_LEVEL", None::<&str>, || {
883 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
884 });
885 }
886
887 #[test]
888 fn cdp_fix_mode_from_env_variants() {
889 use crate::cdp_protection::CdpFixMode;
890 let cases = [
891 ("add_binding", CdpFixMode::AddBinding),
892 ("isolatedworld", CdpFixMode::IsolatedWorld),
893 ("enable_disable", CdpFixMode::EnableDisable),
894 ("none", CdpFixMode::None),
895 ("unknown_value", CdpFixMode::AddBinding), ];
897 for (val, expected) in cases {
898 temp_env::with_var("STYGIAN_CDP_FIX_MODE", Some(val), || {
899 assert_eq!(
900 CdpFixMode::from_env(),
901 expected,
902 "STYGIAN_CDP_FIX_MODE={val}"
903 );
904 });
905 }
906 }
907
908 #[test]
909 fn pool_config_from_env_min_max() {
910 temp_env::with_vars(
911 [
912 ("STYGIAN_POOL_MIN", Some("3")),
913 ("STYGIAN_POOL_MAX", Some("15")),
914 ],
915 || {
916 let p = PoolConfig::default();
917 assert_eq!(p.min_size, 3);
918 assert_eq!(p.max_size, 15);
919 },
920 );
921 }
922
923 #[test]
924 fn headless_from_env_false() {
925 temp_env::with_var("STYGIAN_HEADLESS", Some("false"), || {
926 assert!(!env_bool("STYGIAN_HEADLESS", true));
928 });
929 }
930
931 #[test]
932 fn headless_from_env_zero_means_false() {
933 temp_env::with_var("STYGIAN_HEADLESS", Some("0"), || {
934 assert!(!env_bool("STYGIAN_HEADLESS", true));
935 });
936 }
937
938 #[test]
939 fn headless_from_env_no_means_false() {
940 temp_env::with_var("STYGIAN_HEADLESS", Some("no"), || {
941 assert!(!env_bool("STYGIAN_HEADLESS", true));
942 });
943 }
944
945 #[test]
946 fn validate_accepts_socks4_proxy() {
947 let cfg = BrowserConfig {
948 proxy: Some("socks4://127.0.0.1:1080".to_string()),
949 ..BrowserConfig::default()
950 };
951 assert!(cfg.validate().is_ok());
952 }
953
954 #[test]
955 fn validate_multiple_errors_returned_together() {
956 let cfg = BrowserConfig {
957 pool: PoolConfig {
958 min_size: 10,
959 max_size: 5,
960 ..PoolConfig::default()
961 },
962 launch_timeout: std::time::Duration::ZERO,
963 proxy: Some("ftp://bad".to_string()),
964 ..BrowserConfig::default()
965 };
966 let result = cfg.validate();
967 assert!(result.is_err());
968 if let Err(errors) = result {
969 assert!(errors.len() >= 3, "expected ≥3 errors, got: {errors:?}");
970 }
971 }
972
973 #[test]
974 fn json_file_error_on_missing_file() {
975 let result = BrowserConfig::from_json_file("/nonexistent/path/config.json");
976 assert!(result.is_err());
977 if let Err(e) = result {
978 let err_str = e.to_string();
979 assert!(err_str.contains("cannot read config file") || err_str.contains("config"));
980 }
981 }
982
983 #[test]
984 fn json_roundtrip_preserves_cdp_fix_mode() -> Result<(), Box<dyn std::error::Error>> {
985 use crate::cdp_protection::CdpFixMode;
986 let cfg = BrowserConfig::builder()
987 .cdp_fix_mode(CdpFixMode::EnableDisable)
988 .build();
989 let json = cfg.to_json()?;
990 let back = BrowserConfig::from_json_str(&json)?;
991 assert_eq!(back.cdp_fix_mode, CdpFixMode::EnableDisable);
992 Ok(())
993 }
994}
995
996#[cfg(test)]
1002#[allow(unsafe_code)] mod temp_env {
1004 use std::env;
1005 use std::ffi::OsStr;
1006 use std::sync::Mutex;
1007
1008 static ENV_LOCK: Mutex<()> = Mutex::new(());
1010
1011 pub fn with_var<K, V, F>(key: K, value: 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 key = key.as_ref();
1023 let prev = env::var_os(key);
1024 match value {
1025 Some(v) => unsafe { env::set_var(key, v.as_ref()) },
1026 None => unsafe { env::remove_var(key) },
1027 }
1028 f();
1029 match prev {
1030 Some(v) => unsafe { env::set_var(key, v) },
1031 None => unsafe { env::remove_var(key) },
1032 }
1033 }
1034
1035 pub fn with_vars<K, V, F>(pairs: impl IntoIterator<Item = (K, Option<V>)>, f: F)
1037 where
1038 K: AsRef<OsStr>,
1039 V: AsRef<OsStr>,
1040 F: FnOnce(),
1041 {
1042 let _guard = ENV_LOCK
1043 .lock()
1044 .unwrap_or_else(std::sync::PoisonError::into_inner);
1045 let pairs: Vec<_> = pairs
1046 .into_iter()
1047 .map(|(k, v)| {
1048 let key = k.as_ref().to_os_string();
1049 let prev = env::var_os(&key);
1050 let new_val = v.map(|v| v.as_ref().to_os_string());
1051 (key, prev, new_val)
1052 })
1053 .collect();
1054
1055 for (key, _, new_val) in &pairs {
1056 match new_val {
1057 Some(v) => unsafe { env::set_var(key, v) },
1058 None => unsafe { env::remove_var(key) },
1059 }
1060 }
1061
1062 f();
1063
1064 for (key, prev, _) in &pairs {
1065 match prev {
1066 Some(v) => unsafe { env::set_var(key, v) },
1067 None => unsafe { env::remove_var(key) },
1068 }
1069 }
1070 }
1071}