use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use crate::cdp_protection::CdpFixMode;
#[cfg(feature = "stealth")]
use crate::webrtc::WebRtcConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum HeadlessMode {
#[default]
New,
Legacy,
}
impl HeadlessMode {
pub fn from_env() -> Self {
match std::env::var("STYGIAN_HEADLESS_MODE")
.unwrap_or_default()
.to_lowercase()
.as_str()
{
"legacy" => Self::Legacy,
_ => Self::New,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum StealthLevel {
None,
Basic,
#[default]
Advanced,
}
impl StealthLevel {
#[must_use]
pub fn is_active(self) -> bool {
self != Self::None
}
pub fn from_env() -> Self {
match std::env::var("STYGIAN_STEALTH_LEVEL")
.unwrap_or_default()
.to_lowercase()
.as_str()
{
"none" => Self::None,
"basic" => Self::Basic,
_ => Self::Advanced,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolConfig {
pub min_size: usize,
pub max_size: usize,
#[serde(with = "duration_secs")]
pub idle_timeout: Duration,
#[serde(with = "duration_secs")]
pub acquire_timeout: Duration,
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
min_size: env_usize("STYGIAN_POOL_MIN", 2),
max_size: env_usize("STYGIAN_POOL_MAX", 10),
idle_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_IDLE_SECS", 300)),
acquire_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_ACQUIRE_SECS", 5)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserConfig {
pub chrome_path: Option<PathBuf>,
pub args: Vec<String>,
pub headless: bool,
pub user_data_dir: Option<PathBuf>,
pub headless_mode: HeadlessMode,
pub window_size: Option<(u32, u32)>,
pub devtools: bool,
pub proxy: Option<String>,
pub proxy_bypass_list: Option<String>,
#[cfg(feature = "stealth")]
pub webrtc: WebRtcConfig,
pub stealth_level: StealthLevel,
pub disable_sandbox: bool,
pub cdp_fix_mode: CdpFixMode,
pub source_url: Option<String>,
pub pool: PoolConfig,
#[serde(with = "duration_secs")]
pub launch_timeout: Duration,
#[serde(with = "duration_secs")]
pub cdp_timeout: Duration,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
chrome_path: std::env::var("STYGIAN_CHROME_PATH").ok().map(PathBuf::from),
args: vec![],
headless: env_bool("STYGIAN_HEADLESS", true),
user_data_dir: None,
headless_mode: HeadlessMode::from_env(),
window_size: Some((1920, 1080)),
devtools: false,
proxy: std::env::var("STYGIAN_PROXY").ok(),
proxy_bypass_list: std::env::var("STYGIAN_PROXY_BYPASS").ok(),
#[cfg(feature = "stealth")]
webrtc: WebRtcConfig::default(),
disable_sandbox: env_bool("STYGIAN_DISABLE_SANDBOX", is_containerized()),
stealth_level: StealthLevel::from_env(),
cdp_fix_mode: CdpFixMode::from_env(),
source_url: std::env::var("STYGIAN_SOURCE_URL").ok(),
pool: PoolConfig::default(),
launch_timeout: Duration::from_secs(env_u64("STYGIAN_LAUNCH_TIMEOUT_SECS", 10)),
cdp_timeout: Duration::from_secs(env_u64("STYGIAN_CDP_TIMEOUT_SECS", 30)),
}
}
}
impl BrowserConfig {
pub fn builder() -> BrowserConfigBuilder {
BrowserConfigBuilder {
config: Self::default(),
}
}
pub fn effective_args(&self) -> Vec<String> {
let mut args = vec![
"--disable-blink-features=AutomationControlled".to_string(),
"--disable-dev-shm-usage".to_string(),
"--disable-infobars".to_string(),
"--disable-background-timer-throttling".to_string(),
"--disable-backgrounding-occluded-windows".to_string(),
"--disable-renderer-backgrounding".to_string(),
];
if self.disable_sandbox {
args.push("--no-sandbox".to_string());
}
if let Some(proxy) = &self.proxy {
args.push(format!("--proxy-server={proxy}"));
}
if let Some(bypass) = &self.proxy_bypass_list {
args.push(format!("--proxy-bypass-list={bypass}"));
}
#[cfg(feature = "stealth")]
args.extend(self.webrtc.chrome_args());
if let Some((w, h)) = self.window_size {
args.push(format!("--window-size={w},{h}"));
}
args.extend_from_slice(&self.args);
args
}
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors: Vec<String> = Vec::new();
if self.pool.min_size > self.pool.max_size {
errors.push(format!(
"pool.min_size ({}) must be <= pool.max_size ({})",
self.pool.min_size, self.pool.max_size
));
}
if self.pool.max_size == 0 {
errors.push("pool.max_size must be >= 1".to_string());
}
if self.launch_timeout.is_zero() {
errors.push("launch_timeout must be positive".to_string());
}
if self.cdp_timeout.is_zero() {
errors.push("cdp_timeout must be positive".to_string());
}
if let Some(proxy) = &self.proxy
&& !proxy.starts_with("http://")
&& !proxy.starts_with("https://")
&& !proxy.starts_with("socks4://")
&& !proxy.starts_with("socks5://")
{
errors.push(format!(
"proxy URL must start with http://, https://, socks4:// or socks5://; got: {proxy}"
));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(s)
}
pub fn from_json_file(path: impl AsRef<std::path::Path>) -> crate::error::Result<Self> {
use crate::error::BrowserError;
let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
BrowserError::ConfigError(format!(
"cannot read config file {}: {e}",
path.as_ref().display()
))
})?;
serde_json::from_str(&content).map_err(|e| {
BrowserError::ConfigError(format!(
"invalid JSON in config file {}: {e}",
path.as_ref().display()
))
})
}
}
pub struct BrowserConfigBuilder {
config: BrowserConfig,
}
impl BrowserConfigBuilder {
#[must_use]
pub fn chrome_path(mut self, path: PathBuf) -> Self {
self.config.chrome_path = Some(path);
self
}
#[must_use]
pub fn user_data_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.config.user_data_dir = Some(path.into());
self
}
#[must_use]
pub const fn headless(mut self, headless: bool) -> Self {
self.config.headless = headless;
self
}
#[must_use]
pub const fn headless_mode(mut self, mode: HeadlessMode) -> Self {
self.config.headless_mode = mode;
self
}
#[must_use]
pub const fn window_size(mut self, width: u32, height: u32) -> Self {
self.config.window_size = Some((width, height));
self
}
#[must_use]
pub const fn devtools(mut self, enabled: bool) -> Self {
self.config.devtools = enabled;
self
}
#[must_use]
pub fn proxy(mut self, proxy: String) -> Self {
self.config.proxy = Some(proxy);
self
}
#[must_use]
pub fn proxy_bypass_list(mut self, bypass: String) -> Self {
self.config.proxy_bypass_list = Some(bypass);
self
}
#[cfg(feature = "stealth")]
#[must_use]
pub fn webrtc(mut self, webrtc: WebRtcConfig) -> Self {
self.config.webrtc = webrtc;
self
}
#[must_use]
pub fn arg(mut self, arg: String) -> Self {
self.config.args.push(arg);
self
}
#[cfg(feature = "stealth")]
#[must_use]
pub fn tls_profile(mut self, profile: &crate::tls::TlsProfile) -> Self {
self.config
.args
.extend(crate::tls::chrome_tls_args(profile));
self
}
#[must_use]
pub const fn stealth_level(mut self, level: StealthLevel) -> Self {
self.config.stealth_level = level;
self
}
#[must_use]
pub const fn disable_sandbox(mut self, disable: bool) -> Self {
self.config.disable_sandbox = disable;
self
}
#[must_use]
pub const fn cdp_fix_mode(mut self, mode: CdpFixMode) -> Self {
self.config.cdp_fix_mode = mode;
self
}
#[must_use]
pub fn source_url(mut self, url: Option<String>) -> Self {
self.config.source_url = url;
self
}
#[must_use]
pub const fn pool(mut self, pool: PoolConfig) -> Self {
self.config.pool = pool;
self
}
pub fn build(self) -> BrowserConfig {
self.config
}
}
mod duration_secs {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
d.as_secs().serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Duration, D::Error> {
Ok(Duration::from_secs(u64::deserialize(d)?))
}
}
fn env_bool(key: &str, default: bool) -> bool {
std::env::var(key)
.map(|v| !matches!(v.to_lowercase().as_str(), "false" | "0" | "no"))
.unwrap_or(default)
}
#[allow(clippy::missing_const_for_fn)] fn is_containerized() -> bool {
#[cfg(target_os = "linux")]
{
if std::path::Path::new("/.dockerenv").exists() {
return true;
}
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
&& (cgroup.contains("docker") || cgroup.contains("kubepods"))
{
return true;
}
false
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
fn env_u64(key: &str, default: u64) -> u64 {
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn env_usize(key: &str, default: usize) -> usize {
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_headless() {
let cfg = BrowserConfig::default();
assert!(cfg.headless);
}
#[test]
fn builder_roundtrip() {
let cfg = BrowserConfig::builder()
.headless(false)
.window_size(1280, 720)
.stealth_level(StealthLevel::Basic)
.build();
assert!(!cfg.headless);
assert_eq!(cfg.window_size, Some((1280, 720)));
assert_eq!(cfg.stealth_level, StealthLevel::Basic);
}
#[test]
fn effective_args_include_anti_detection_flag() {
let cfg = BrowserConfig::default();
let args = cfg.effective_args();
assert!(args.iter().any(|a| a.contains("AutomationControlled")));
}
#[test]
fn no_sandbox_only_when_explicitly_enabled() {
let with_sandbox_disabled = BrowserConfig::builder().disable_sandbox(true).build();
assert!(
with_sandbox_disabled
.effective_args()
.iter()
.any(|a| a == "--no-sandbox")
);
let with_sandbox_enabled = BrowserConfig::builder().disable_sandbox(false).build();
assert!(
!with_sandbox_enabled
.effective_args()
.iter()
.any(|a| a == "--no-sandbox")
);
}
#[test]
fn pool_config_defaults() {
let p = PoolConfig::default();
assert_eq!(p.min_size, 2);
assert_eq!(p.max_size, 10);
}
#[test]
fn stealth_level_none_not_active() {
assert!(!StealthLevel::None.is_active());
assert!(StealthLevel::Basic.is_active());
assert!(StealthLevel::Advanced.is_active());
}
#[test]
fn config_serialization() -> Result<(), Box<dyn std::error::Error>> {
let cfg = BrowserConfig::default();
let json = serde_json::to_string(&cfg)?;
let back: BrowserConfig = serde_json::from_str(&json)?;
assert_eq!(back.headless, cfg.headless);
assert_eq!(back.stealth_level, cfg.stealth_level);
Ok(())
}
#[test]
fn validate_default_config_is_valid() {
let cfg = BrowserConfig::default();
assert!(cfg.validate().is_ok(), "default config must be valid");
}
#[test]
fn validate_detects_pool_size_inversion() {
let cfg = BrowserConfig {
pool: PoolConfig {
min_size: 10,
max_size: 5,
..PoolConfig::default()
},
..BrowserConfig::default()
};
let result = cfg.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.iter().any(|e| e.contains("min_size")));
}
}
#[test]
fn validate_detects_zero_max_pool() {
let cfg = BrowserConfig {
pool: PoolConfig {
max_size: 0,
..PoolConfig::default()
},
..BrowserConfig::default()
};
let result = cfg.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.iter().any(|e| e.contains("max_size")));
}
}
#[test]
fn validate_detects_zero_timeouts() {
let cfg = BrowserConfig {
launch_timeout: std::time::Duration::ZERO,
cdp_timeout: std::time::Duration::ZERO,
..BrowserConfig::default()
};
let result = cfg.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert_eq!(errors.len(), 2);
}
}
#[test]
fn validate_detects_bad_proxy_scheme() {
let cfg = BrowserConfig {
proxy: Some("ftp://bad.proxy:1234".to_string()),
..BrowserConfig::default()
};
let result = cfg.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.iter().any(|e| e.contains("proxy URL")));
}
}
#[test]
fn validate_accepts_valid_proxy() {
let cfg = BrowserConfig {
proxy: Some("socks5://user:pass@127.0.0.1:1080".to_string()),
..BrowserConfig::default()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn to_json_and_from_json_str_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let cfg = BrowserConfig::builder()
.headless(false)
.stealth_level(StealthLevel::Basic)
.build();
let json = cfg.to_json()?;
assert!(json.contains("headless"));
let back = BrowserConfig::from_json_str(&json)?;
assert!(!back.headless);
assert_eq!(back.stealth_level, StealthLevel::Basic);
Ok(())
}
#[test]
fn from_json_str_error_on_invalid_json() {
let err = BrowserConfig::from_json_str("not json at all");
assert!(err.is_err());
}
#[test]
fn builder_cdp_fix_mode_and_source_url() {
use crate::cdp_protection::CdpFixMode;
let cfg = BrowserConfig::builder()
.cdp_fix_mode(CdpFixMode::IsolatedWorld)
.source_url(Some("stealth.js".to_string()))
.build();
assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
assert_eq!(cfg.source_url.as_deref(), Some("stealth.js"));
}
#[test]
fn builder_source_url_none_disables_sourceurl() {
let cfg = BrowserConfig::builder().source_url(None).build();
assert!(cfg.source_url.is_none());
}
#[test]
fn stealth_level_from_env_none() {
temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("none"), || {
let level = StealthLevel::from_env();
assert_eq!(level, StealthLevel::None);
});
}
#[test]
fn stealth_level_from_env_basic() {
temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("basic"), || {
assert_eq!(StealthLevel::from_env(), StealthLevel::Basic);
});
}
#[test]
fn stealth_level_from_env_advanced_is_default() {
temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("anything_else"), || {
assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
});
}
#[test]
fn stealth_level_from_env_missing_defaults_to_advanced() {
temp_env::with_var("STYGIAN_STEALTH_LEVEL", None::<&str>, || {
assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
});
}
#[test]
fn cdp_fix_mode_from_env_variants() {
use crate::cdp_protection::CdpFixMode;
let cases = [
("add_binding", CdpFixMode::AddBinding),
("isolatedworld", CdpFixMode::IsolatedWorld),
("enable_disable", CdpFixMode::EnableDisable),
("none", CdpFixMode::None),
("unknown_value", CdpFixMode::AddBinding), ];
for (val, expected) in cases {
temp_env::with_var("STYGIAN_CDP_FIX_MODE", Some(val), || {
assert_eq!(
CdpFixMode::from_env(),
expected,
"STYGIAN_CDP_FIX_MODE={val}"
);
});
}
}
#[test]
fn pool_config_from_env_min_max() {
temp_env::with_vars(
[
("STYGIAN_POOL_MIN", Some("3")),
("STYGIAN_POOL_MAX", Some("15")),
],
|| {
let p = PoolConfig::default();
assert_eq!(p.min_size, 3);
assert_eq!(p.max_size, 15);
},
);
}
#[test]
fn headless_from_env_false() {
temp_env::with_var("STYGIAN_HEADLESS", Some("false"), || {
assert!(!env_bool("STYGIAN_HEADLESS", true));
});
}
#[test]
fn headless_from_env_zero_means_false() {
temp_env::with_var("STYGIAN_HEADLESS", Some("0"), || {
assert!(!env_bool("STYGIAN_HEADLESS", true));
});
}
#[test]
fn headless_from_env_no_means_false() {
temp_env::with_var("STYGIAN_HEADLESS", Some("no"), || {
assert!(!env_bool("STYGIAN_HEADLESS", true));
});
}
#[test]
fn validate_accepts_socks4_proxy() {
let cfg = BrowserConfig {
proxy: Some("socks4://127.0.0.1:1080".to_string()),
..BrowserConfig::default()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn validate_multiple_errors_returned_together() {
let cfg = BrowserConfig {
pool: PoolConfig {
min_size: 10,
max_size: 5,
..PoolConfig::default()
},
launch_timeout: std::time::Duration::ZERO,
proxy: Some("ftp://bad".to_string()),
..BrowserConfig::default()
};
let result = cfg.validate();
assert!(result.is_err());
if let Err(errors) = result {
assert!(errors.len() >= 3, "expected ≥3 errors, got: {errors:?}");
}
}
#[test]
fn json_file_error_on_missing_file() {
let result = BrowserConfig::from_json_file("/nonexistent/path/config.json");
assert!(result.is_err());
if let Err(e) = result {
let err_str = e.to_string();
assert!(err_str.contains("cannot read config file") || err_str.contains("config"));
}
}
#[test]
fn json_roundtrip_preserves_cdp_fix_mode() -> Result<(), Box<dyn std::error::Error>> {
use crate::cdp_protection::CdpFixMode;
let cfg = BrowserConfig::builder()
.cdp_fix_mode(CdpFixMode::EnableDisable)
.build();
let json = cfg.to_json()?;
let back = BrowserConfig::from_json_str(&json)?;
assert_eq!(back.cdp_fix_mode, CdpFixMode::EnableDisable);
Ok(())
}
}
#[cfg(test)]
#[allow(unsafe_code)] mod temp_env {
use std::env;
use std::ffi::OsStr;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
pub fn with_var<K, V, F>(key: K, value: Option<V>, f: F)
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
F: FnOnce(),
{
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let key = key.as_ref();
let prev = env::var_os(key);
match value {
Some(v) => unsafe { env::set_var(key, v.as_ref()) },
None => unsafe { env::remove_var(key) },
}
f();
match prev {
Some(v) => unsafe { env::set_var(key, v) },
None => unsafe { env::remove_var(key) },
}
}
pub fn with_vars<K, V, F>(pairs: impl IntoIterator<Item = (K, Option<V>)>, f: F)
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
F: FnOnce(),
{
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let pairs: Vec<_> = pairs
.into_iter()
.map(|(k, v)| {
let key = k.as_ref().to_os_string();
let prev = env::var_os(&key);
let new_val = v.map(|v| v.as_ref().to_os_string());
(key, prev, new_val)
})
.collect();
for (key, _, new_val) in &pairs {
match new_val {
Some(v) => unsafe { env::set_var(key, v) },
None => unsafe { env::remove_var(key) },
}
}
f();
for (key, prev, _) in &pairs {
match prev {
Some(v) => unsafe { env::set_var(key, v) },
None => unsafe { env::remove_var(key) },
}
}
}
}