use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
static BROWSER_COUNTER: AtomicU64 = AtomicU64::new(0);
use crate::cdp::transport::launch_chrome_with_profile_dir;
use crate::cdp::types::{EmulationSetUserAgentOverride, UserAgentBrandVersion, UserAgentMetadata};
use crate::cdp::{Connection, Transport};
use crate::error::{Error, Result};
use crate::page::Page;
use crate::stealth::fingerprint::Fingerprint;
use crate::stealth::{build_evasion_script_for, find_chrome, ChromePatcher};
use crate::StealthConfig;
fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
const ATTEMPTS: usize = 10;
for attempt in 0..ATTEMPTS {
match std::fs::remove_dir_all(path) {
Ok(()) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) if is_directory_not_empty(&error) && attempt + 1 < ATTEMPTS => {
std::thread::sleep(Duration::from_millis(50));
}
Err(error) => return Err(error),
}
}
Ok(())
}
fn is_directory_not_empty(error: &std::io::Error) -> bool {
matches!(error.raw_os_error(), Some(39) | Some(66) | Some(145))
}
#[cfg(unix)]
fn restrict_profile_perms(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o700);
if let Err(error) = std::fs::set_permissions(path, perms) {
tracing::warn!(
"Failed to restrict profile dir {}: {}",
path.display(),
error
);
}
}
#[cfg(not(unix))]
fn restrict_profile_perms(_path: &Path) {}
async fn discover_browser_ws(port: u16) -> Result<String> {
tokio::task::spawn_blocking(move || {
crate::cdp::discover::discover_browser_ws("127.0.0.1", port)
})
.await
.map_err(|error| Error::transport(format!("Browser discovery worker failed: {}", error)))?
}
fn stealth_args(config: &StealthConfig, fingerprint: &Fingerprint) -> Vec<String> {
let mut args = vec![
"--disable-blink-features=AutomationControlled".into(),
"--disable-features=IsolateOrigins,site-per-process,AutomationControlled,EnableAutomation"
.into(),
"--enable-features=NetworkService,NetworkServiceInProcess".into(),
"--disable-infobars".into(),
"--disable-dev-shm-usage".into(),
"--disable-ipc-flooding-protection".into(),
"--disable-renderer-backgrounding".into(),
"--disable-background-timer-throttling".into(),
"--disable-backgrounding-occluded-windows".into(),
"--no-first-run".into(),
"--no-default-browser-check".into(),
"--no-sandbox".into(),
"--disable-default-apps".into(),
"--disable-component-extensions-with-background-pages".into(),
"--disable-hang-monitor".into(),
"--disable-popup-blocking".into(),
"--disable-prompt-on-repost".into(),
"--disable-sync".into(),
"--disable-translate".into(),
"--metrics-recording-only".into(),
"--safebrowsing-disable-auto-update".into(),
"--disable-client-side-phishing-detection".into(),
"--password-store=basic".into(),
"--use-mock-keychain".into(),
"--lang=en-US".into(),
"--disable-field-trial-config".into(),
format!(
"--window-size={},{}",
config.viewport_width, config.viewport_height
),
format!(
"--ozone-override-screen-size={},{}",
config.viewport_width, config.viewport_height
),
];
args.push(format!("--user-agent={}", fingerprint.user_agent));
if config.headless {
args.push("--headless=new".into());
}
if let Some(ref profile_dir) = config.profile_dir {
args.push(format!("--profile-directory={}", profile_dir));
}
if let Some(ref proxy) = config.proxy {
args.push(format!("--proxy-server={}", proxy));
}
for arg in &config.extra_args {
args.push(arg.clone());
}
args
}
fn stop_unclaimed_child(child: &mut Child) -> std::io::Result<()> {
if child.try_wait()?.is_some() {
return Ok(());
}
if let Err(kill_error) = child.kill() {
if child.try_wait()?.is_none() {
return Err(kill_error);
}
return Ok(());
}
child.wait()?;
Ok(())
}
struct BrowserProfileGuard {
path: PathBuf,
owned: bool,
}
impl BrowserProfileGuard {
fn create(config: &StealthConfig) -> Result<Self> {
match config.user_data_dir.as_deref() {
Some(path) => {
let path = PathBuf::from(path);
std::fs::create_dir_all(&path)?;
restrict_profile_perms(&path);
Ok(Self { path, owned: false })
}
None => {
let instance_id = BROWSER_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"eoka-browser-{}-{}",
std::process::id(),
instance_id
));
remove_dir_if_exists(&path)?;
std::fs::create_dir_all(&path)?;
restrict_profile_perms(&path);
Ok(Self { path, owned: true })
}
}
}
fn browser_owned_path(&mut self) -> Option<PathBuf> {
if self.owned {
self.owned = false;
Some(self.path.clone())
} else {
None
}
}
}
impl Drop for BrowserProfileGuard {
fn drop(&mut self) {
if self.owned {
if let Err(error) = remove_dir_if_exists(&self.path) {
tracing::warn!("Failed to remove unclaimed browser profile: {}", error);
}
}
}
}
struct PreparedBrowserLaunch {
child: Option<Child>,
ws_url: String,
fingerprint: Option<Fingerprint>,
profile: BrowserProfileGuard,
}
impl Drop for PreparedBrowserLaunch {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
if let Err(error) = stop_unclaimed_child(&mut child) {
tracing::warn!("Failed to clean up unclaimed Chrome launch: {}", error);
}
}
}
}
fn try_attach_existing(profile_dir: &Path, want_headless: bool) -> Result<Option<String>> {
use crate::cdp::discover;
let Some(pid) = discover::read_singleton_lock(profile_dir) else {
return Ok(None);
};
if !discover::pid_is_alive(pid) {
let _ = std::fs::remove_file(profile_dir.join("SingletonLock"));
return Ok(None);
}
if let Some(argv) = discover::read_process_argv(pid) {
let running_headless = argv.iter().any(|arg| arg.starts_with("--headless"));
if running_headless != want_headless {
let (running, requested) = if running_headless {
("headless", "headed")
} else {
("non-headless", "headless")
};
return Err(Error::Launch(format!(
"A {} Chrome instance (pid {}) is already running on profile {}; close it \
before requesting {} mode, or drop that flag for this launch",
running,
pid,
profile_dir.display(),
requested
)));
}
}
let Some(port) = discover::read_devtools_active_port(profile_dir) else {
return Err(Error::Launch(format!(
"A Chrome instance (pid {}) is already running on profile {}, but it wasn't \
launched with a debug port; close it first or relaunch it with \
--remote-debugging-port",
pid,
profile_dir.display()
)));
};
let ws_url = discover::discover_browser_ws("127.0.0.1", port).map_err(|e| {
Error::Launch(format!(
"A Chrome instance (pid {}) is already running on profile {}, but its DevTools \
port {} isn't responding: {}",
pid,
profile_dir.display(),
port,
e
))
})?;
Ok(Some(ws_url))
}
fn prepare_browser_launch(config: Arc<StealthConfig>) -> Result<PreparedBrowserLaunch> {
let profile = BrowserProfileGuard::create(&config)?;
if !profile.owned {
if let Some(ws_url) = try_attach_existing(&profile.path, config.headless)? {
tracing::info!("Reusing running Chrome on profile {:?}", profile.path);
let fingerprint = Fingerprint::resolve_for_profile(
config.user_agent.as_deref(),
config.timezone.as_deref(),
Some(profile.path.as_path()),
)?;
return Ok(PreparedBrowserLaunch {
child: None,
ws_url,
fingerprint: Some(fingerprint),
profile,
});
}
}
let chrome_path = match &config.chrome_path {
Some(path) => PathBuf::from(path),
None => find_chrome()?,
};
let chrome_path = if config.patch_binary && profile.owned {
ChromePatcher::new(&chrome_path)?.get_patched_path()?
} else {
chrome_path
};
let detected_user_agent = if config.user_agent.is_none() {
installed_chrome_user_agent(&chrome_path)
} else {
None
};
let fingerprint = Fingerprint::resolve_for_profile(
config
.user_agent
.as_deref()
.or(detected_user_agent.as_deref()),
config.timezone.as_deref(),
(!profile.owned).then_some(profile.path.as_path()),
)?;
let mut args = stealth_args(&config, &fingerprint);
args.push(format!("--user-data-dir={}", profile.path.display()));
tracing::info!("Launching Chrome from {:?}", chrome_path);
let (child, ws_url) = launch_chrome_with_profile_dir(&chrome_path, &args, &profile.path)?;
Ok(PreparedBrowserLaunch {
child: Some(child),
ws_url,
fingerprint: Some(fingerprint),
profile,
})
}
#[derive(Debug, Clone)]
pub struct TabInfo {
pub id: String,
pub title: String,
pub url: String,
}
const GEO_API_URL: &str = "https://ipwho.is/";
const GEO_TRACE_URL: &str = "https://www.cloudflare.com/cdn-cgi/trace";
#[derive(Debug, Clone)]
struct GeoInfo {
timezone: Option<String>,
country: String,
}
fn parse_geo_api(text: &str) -> Option<GeoInfo> {
let value: Value = serde_json::from_str(text).ok()?;
let timezone = value.pointer("/timezone/id")?.as_str()?.to_string();
let country = value.get("country_code")?.as_str()?.to_string();
if timezone.is_empty()
|| country.len() != 2
|| !country.bytes().all(|byte| byte.is_ascii_alphabetic())
{
return None;
}
Some(GeoInfo {
timezone: Some(timezone),
country: country.to_uppercase(),
})
}
fn parse_geo_trace(text: &str) -> Option<GeoInfo> {
let mut country = None;
for line in text.lines() {
if let Some(value) = line.strip_prefix("loc=") {
country = Some(value.trim().to_string());
}
}
let country = country.filter(|c| c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic()))?;
Some(GeoInfo {
timezone: None,
country: country.to_uppercase(),
})
}
fn languages_for_country(country: &str) -> Vec<String> {
let languages: &[&str] = match country {
"US" => &["en-US", "en"],
"GB" => &["en-GB", "en"],
"AU" | "NZ" => &["en-AU", "en"],
"CA" => &["en-CA", "fr-CA", "en", "fr"],
"IE" => &["en-IE", "en"],
"IN" => &["en-IN", "hi-IN", "en", "hi"],
"DE" | "AT" => &["de-DE", "de"],
"CH" => &["de-CH", "fr-CH", "it-CH", "de", "fr", "it"],
"FR" | "LU" => &["fr-FR", "fr"],
"BE" => &["nl-BE", "fr-BE", "nl", "fr"],
"ES" | "MX" | "AR" | "CL" | "CO" => &["es-ES", "es"],
"BR" => &["pt-BR", "pt"],
"PT" => &["pt-PT", "pt"],
"IT" => &["it-IT", "it"],
"NL" => &["nl-NL", "nl"],
"SE" => &["sv-SE", "sv"],
"NO" => &["nb-NO", "no"],
"DK" => &["da-DK", "da"],
"FI" => &["fi-FI", "fi"],
"PL" => &["pl-PL", "pl"],
"CZ" | "SK" => &["cs-CZ", "cs"],
"HU" => &["hu-HU", "hu"],
"RO" => &["ro-RO", "ro"],
"GR" => &["el-GR", "el"],
"TR" => &["tr-TR", "tr"],
"RU" | "BY" | "KZ" => &["ru-RU", "ru"],
"UA" => &["uk-UA", "ru", "uk"],
"JP" => &["ja-JP", "ja"],
"KR" => &["ko-KR", "ko"],
"CN" => &["zh-CN", "zh"],
"TW" | "HK" => &["zh-TW", "zh"],
"VN" => &["vi-VN", "vi"],
"TH" => &["th-TH", "th"],
"ID" => &["id-ID", "id"],
"SA" | "AE" | "EG" => &["ar-SA", "ar"],
"IL" => &["he-IL", "he"],
_ => &["en-US", "en"],
};
languages.iter().map(|s| (*s).to_string()).collect()
}
async fn read_remote_text(page: &Page, url: &str) -> Result<String> {
page.goto(url).await?;
page.text().await
}
fn ua_override_for(fp: &Fingerprint) -> EmulationSetUserAgentOverride {
let to_brands = |v: Vec<(String, String)>| -> Vec<UserAgentBrandVersion> {
v.into_iter()
.map(|(brand, version)| UserAgentBrandVersion { brand, version })
.collect()
};
let architecture = fp.ch_architecture();
EmulationSetUserAgentOverride {
user_agent: fp.user_agent.clone(),
accept_language: Some(accept_language_for(&fp.languages)),
platform: Some(fp.nav_platform().to_string()),
user_agent_metadata: Some(UserAgentMetadata {
brands: to_brands(fp.ch_brands()),
full_version_list: to_brands(fp.ch_full_version_brands()),
platform: fp.ch_platform().to_string(),
platform_version: fp.platform_version.clone(),
architecture: architecture.to_string(),
model: String::new(),
mobile: false,
bitness: "64".to_string(),
wow64: false,
}),
}
}
fn accept_language_for(languages: &[String]) -> String {
languages
.iter()
.enumerate()
.map(|(index, language)| match index {
0 => language.clone(),
_ => format!("{};q={:.1}", language, (1.0 - 0.1 * index as f64).max(0.1)),
})
.collect::<Vec<_>>()
.join(",")
}
pub struct Browser {
connection: Connection,
config: Arc<StealthConfig>,
user_data_dir: Option<PathBuf>,
reused: bool,
fingerprint: Option<Fingerprint>,
evasion_script: String,
}
impl Browser {
pub async fn launch() -> Result<Self> {
Self::launch_with_config(StealthConfig::default()).await
}
pub async fn launch_visible() -> Result<Self> {
Self::launch_with_config(StealthConfig::visible()).await
}
pub async fn launch_debug() -> Result<Self> {
Self::launch_with_config(StealthConfig::debug()).await
}
pub async fn launch_with(configure: impl FnOnce(&mut StealthConfig)) -> Result<Self> {
let mut config = StealthConfig::default();
configure(&mut config);
Self::launch_with_config(config).await
}
pub async fn launch_with_config(config: StealthConfig) -> Result<Self> {
let config = Arc::new(config);
let launch_config = Arc::clone(&config);
let mut prepared =
tokio::task::spawn_blocking(move || prepare_browser_launch(launch_config))
.await
.map_err(|error| {
Error::Launch(format!("Browser launch worker failed: {}", error))
})??;
let spawned = prepared.child.is_some();
let transport = match prepared.child.take() {
Some(child) => {
let proxy_auth = match (&config.proxy_username, &config.proxy_password) {
(Some(username), Some(password)) => Some((username.clone(), password.clone())),
_ => None,
};
Transport::new_with_options(
child,
&prepared.ws_url,
proxy_auth,
config.cdp_timeout,
config.strip_x_client_data,
)
.await?
}
None => {
Transport::connect_with_options(
&prepared.ws_url,
config.cdp_timeout,
config.filter_cdp,
config.strip_x_client_data,
)
.await?
}
};
let connection = Connection::new(transport);
let version = connection.version().await?;
tracing::info!("Connected to Chrome: {}", version.product);
let fingerprint = prepared
.fingerprint
.take()
.ok_or_else(|| Error::Launch("Browser launch lost its fingerprint".into()))?;
let user_data_dir = prepared.profile.browser_owned_path();
let evasion_script = build_evasion_script_for(&config, &fingerprint);
let mut browser = Self {
connection,
config,
user_data_dir,
reused: !spawned,
fingerprint: Some(fingerprint),
evasion_script,
};
if spawned && browser.config.geo_align && browser.config.timezone.is_none() {
browser.align_geo().await;
}
Ok(browser)
}
async fn align_geo(&mut self) {
let outcome: Result<GeoInfo> = async {
let page = self.new_blank_page().await?;
let result: Result<GeoInfo> = async {
match read_remote_text(&page, GEO_API_URL)
.await
.ok()
.and_then(|ref text| parse_geo_api(text))
{
Some(geo) => Ok(geo),
None => {
let text = read_remote_text(&page, GEO_TRACE_URL).await?;
parse_geo_trace(&text)
.ok_or_else(|| Error::Launch("geo response missing loc".into()))
}
}
}
.await;
let _ = self.close_tab(page.target_id()).await;
result
}
.await;
match outcome {
Ok(geo) => {
if let Some(fp) = self.fingerprint.as_mut() {
if let Some(timezone) = geo.timezone {
fp.timezone = timezone;
}
fp.languages = languages_for_country(&geo.country);
}
if let Some(fp) = self.fingerprint.as_ref() {
self.evasion_script = build_evasion_script_for(&self.config, fp);
}
tracing::info!(
"Geo-aligned languages/timezone (IP country {})",
geo.country
);
}
Err(error) => {
tracing::warn!("Geo alignment failed, keeping random timezone: {}", error);
}
}
}
pub async fn connect(ws_url: &str) -> Result<Self> {
Self::connect_with_config(ws_url, StealthConfig::live()).await
}
pub async fn connect_with_config(ws_url: &str, config: StealthConfig) -> Result<Self> {
let config = Arc::new(config);
let transport = crate::cdp::transport::Transport::connect_with_options(
ws_url,
config.cdp_timeout,
config.filter_cdp,
config.strip_x_client_data && !config.live_session,
)
.await?;
let connection = Connection::new(transport);
let version = connection.version().await?;
tracing::info!("Connected to Chrome: {}", version.product);
let (fingerprint, evasion_script) = if config.live_session {
(None, String::new())
} else {
let fp = Fingerprint::resolve(config.user_agent.as_deref(), config.timezone.as_deref());
let script = build_evasion_script_for(&config, &fp);
(Some(fp), script)
};
Ok(Self {
connection,
config,
user_data_dir: None,
reused: false,
fingerprint,
evasion_script,
})
}
pub async fn connect_port(port: u16) -> Result<Self> {
let ws_url = discover_browser_ws(port).await?;
Self::connect(&ws_url).await
}
pub async fn connect_port_with_config(port: u16, config: StealthConfig) -> Result<Self> {
let ws_url = discover_browser_ws(port).await?;
Self::connect_with_config(&ws_url, config).await
}
async fn setup_session(&self, session: &crate::cdp::Session) -> Result<()> {
session.page_enable().await?;
let proxy_auth =
self.config.proxy_username.is_some() && self.config.proxy_password.is_some();
if proxy_auth || self.config.strip_x_client_data {
session
.fetch_enable_interception(vec![], proxy_auth)
.await?;
}
if self.config.ignore_cert_errors {
session.set_ignore_cert_errors(true).await?;
}
if !self.config.live_session {
if let Some(ref fp) = self.fingerprint {
session.set_user_agent_full(ua_override_for(fp)).await?;
if let Err(error) = session.set_timezone_override(&fp.timezone).await {
tracing::warn!("Timezone override failed for {}: {}", fp.timezone, error);
}
}
session
.add_script_to_evaluate_on_new_document(&self.evasion_script)
.await?;
}
Ok(())
}
pub async fn new_page(&self, url: &str) -> Result<Page> {
let target_id = self
.connection
.create_target("about:blank", None, None)
.await?;
let session = self.connection.attach_to_target(&target_id).await?;
self.setup_session(&session).await?;
let nav_result = session.navigate(url, None).await?;
if let Some(error) = nav_result.error_text {
return Err(Error::Navigation(error));
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
Ok(Page::new(session, Arc::clone(&self.config)))
}
pub async fn new_blank_page(&self) -> Result<Page> {
let target_id = self
.connection
.create_target("about:blank", None, None)
.await?;
let session = self.connection.attach_to_target(&target_id).await?;
self.setup_session(&session).await?;
Ok(Page::new(session, Arc::clone(&self.config)))
}
pub async fn version(&self) -> Result<String> {
let v = self.connection.version().await?;
Ok(v.product)
}
pub async fn tabs(&self) -> Result<Vec<TabInfo>> {
let targets = self.connection.get_targets().await?;
Ok(targets
.into_iter()
.filter(|t| t.r#type == "page")
.map(|t| TabInfo {
id: t.target_id,
title: t.title,
url: t.url,
})
.collect())
}
pub async fn attach_page(&self, target_id: &str) -> Result<Page> {
let session = self.connection.attach_to_target(target_id).await?;
self.setup_session(&session).await?;
Ok(Page::new(session, Arc::clone(&self.config)))
}
pub async fn activate_tab(&self, target_id: &str) -> Result<()> {
self.connection.activate_target(target_id).await
}
pub async fn close_tab(&self, target_id: &str) -> Result<()> {
self.connection.close_target(target_id).await?;
Ok(())
}
pub async fn close(self) -> Result<()> {
if self.config.live_session || self.reused {
self.connection.transport().close().await?;
} else {
self.connection.close().await?;
}
if let Some(ref dir) = self.user_data_dir {
remove_dir_if_exists(dir)?;
}
Ok(())
}
pub async fn disconnect(self) -> Result<()> {
self.connection.transport().close().await
}
}
fn installed_chrome_user_agent(chrome_path: &std::path::Path) -> Option<String> {
let output = Command::new(chrome_path).arg("--version").output().ok()?;
let version = String::from_utf8_lossy(&output.stdout);
let full_version = version.split_whitespace().find(|part| {
let pieces: Vec<_> = part.split('.').collect();
pieces.len() == 4
&& pieces
.iter()
.all(|piece| !piece.is_empty() && piece.bytes().all(|byte| byte.is_ascii_digit()))
})?;
Fingerprint::native_chrome_user_agent(full_version)
}
impl Drop for Browser {
fn drop(&mut self) {
if let Some(ref dir) = self.user_data_dir {
if let Err(error) = remove_dir_if_exists(dir) {
tracing::warn!(
"Failed to remove temporary browser profile while dropping browser: {}",
error
);
}
}
}
}
#[cfg(all(test, unix))]
mod try_attach_existing_tests {
use super::*;
use std::os::unix::fs::symlink;
fn temp_profile_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"eoka-browser-test-{}-{}-{}",
std::process::id(),
name,
fastrand::u64(..)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn lock_self(dir: &std::path::Path) {
symlink(
format!("some-host-{}", std::process::id()),
dir.join("SingletonLock"),
)
.unwrap();
}
#[test]
fn no_lock_returns_none() {
let dir = temp_profile_dir("no-lock");
assert!(try_attach_existing(&dir, true).unwrap().is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn stale_lock_is_cleared_and_returns_none() {
let dir = temp_profile_dir("stale-lock");
symlink("some-host-2147483647", dir.join("SingletonLock")).unwrap();
assert!(try_attach_existing(&dir, true).unwrap().is_none());
assert!(
!dir.join("SingletonLock").exists(),
"stale lock should have been removed"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn headless_mismatch_errors_before_touching_the_network() {
let dir = temp_profile_dir("headless-mismatch");
lock_self(&dir);
let error = try_attach_existing(&dir, true).unwrap_err().to_string();
assert!(
error.contains("non-headless") && error.contains("requesting headless mode"),
"expected a headless/headed mismatch error, got: {}",
error
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn live_lock_without_debug_port_errors() {
let dir = temp_profile_dir("no-port");
lock_self(&dir);
let error = try_attach_existing(&dir, false).unwrap_err().to_string();
assert!(
error.contains("debug port"),
"expected a missing-debug-port error, got: {}",
error
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn unresponsive_port_errors() {
let dir = temp_profile_dir("bad-port");
lock_self(&dir);
std::fs::write(dir.join("DevToolsActivePort"), "0\n/devtools/browser/x\n").unwrap();
let error = try_attach_existing(&dir, false).unwrap_err().to_string();
assert!(
error.contains("isn't responding"),
"expected an unresponsive-port error, got: {}",
error
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn parses_geo_api_response() {
let body = r#"{"ip":"203.0.113.9","country_code":"DE","timezone":{"id":"Europe/Berlin","abbr":"CEST"}}"#;
let geo = parse_geo_api(body).expect("valid geo api response");
assert_eq!(geo.timezone.as_deref(), Some("Europe/Berlin"));
assert_eq!(geo.country, "DE");
assert!(parse_geo_api("not json").is_none());
assert!(parse_geo_api(r#"{"success":false,"message":"rate limited"}"#).is_none());
}
#[test]
fn parses_geo_trace_response() {
let trace = "fl=123\n\nip=203.0.113.9\nloc=DE\nsnf=oia\n";
let geo = parse_geo_trace(trace).expect("valid trace");
assert_eq!(geo.timezone, None);
assert_eq!(geo.country, "DE");
}
#[test]
fn rejects_malformed_geo_trace() {
assert!(parse_geo_trace("fl=1\nip=1.2.3.4\n").is_none());
assert!(parse_geo_trace("loc=\n").is_none());
assert!(parse_geo_trace("loc=DEU\n").is_none());
assert!(parse_geo_trace("").is_none());
}
#[test]
fn languages_match_ip_country() {
assert_eq!(languages_for_country("DE"), vec!["de-DE", "de"]);
assert_eq!(languages_for_country("JP"), vec!["ja-JP", "ja"]);
assert_eq!(
languages_for_country("CA"),
vec!["en-CA", "fr-CA", "en", "fr"]
);
assert_eq!(languages_for_country("ZZ"), vec!["en-US", "en"]);
}
#[test]
fn stealth_args_disable_field_trials() {
let config = StealthConfig::default();
let fp = Fingerprint::random();
let args = stealth_args(&config, &fp);
assert!(args.iter().any(|arg| arg == "--disable-field-trial-config"));
}
#[cfg(unix)]
#[test]
fn profile_dirs_are_owner_only() {
use std::os::unix::fs::PermissionsExt;
let owned = BrowserProfileGuard::create(&StealthConfig::default()).unwrap();
let mode = std::fs::metadata(&owned.path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "spawned temp profile dir must be 0700");
std::fs::remove_dir_all(&owned.path).unwrap();
let custom = temp_profile_dir("user-supplied");
let guard = BrowserProfileGuard::create(&StealthConfig {
user_data_dir: Some(custom.to_string_lossy().into_owned()),
..StealthConfig::default()
})
.unwrap();
let mode = std::fs::metadata(&guard.path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"user-supplied profile dir must be tightened to 0700"
);
std::fs::remove_dir_all(&custom).unwrap();
}
#[test]
fn accept_language_matches_fingerprint_languages() {
assert_eq!(
accept_language_for(&["en-US".into(), "en".into()]),
"en-US,en;q=0.9"
);
assert_eq!(
accept_language_for(&["de-DE".into(), "de".into(), "en".into()]),
"de-DE,de;q=0.9,en;q=0.8"
);
let fp = Fingerprint::random();
let override_payload = ua_override_for(&fp);
assert_eq!(
override_payload.accept_language.as_deref(),
Some(accept_language_for(&fp.languages).as_str()),
"Accept-Language must be derived from navigator.languages"
);
}
#[test]
fn fonts_and_keyboard_hooks_are_fingerprint_driven() {
let windows_fp = Fingerprint::resolve(
Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7312.86 Safari/537.36"),
None,
);
let windows_script = build_evasion_script_for(&StealthConfig::default(), &windows_fp);
assert!(windows_script.contains("document.fonts.check"));
assert!(
windows_script.contains(&serde_json::to_string(&windows_fp.fonts).unwrap()),
"claimed font set must flow into the script"
);
assert!(windows_script.contains("getLayoutMap"));
let linux_fp = Fingerprint::resolve(
Some("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7312.86 Safari/537.36"),
None,
);
let linux_script = build_evasion_script_for(&StealthConfig::default(), &linux_fp);
assert!(
linux_script
.contains("if (navigator.keyboard && navigator.keyboard.getLayoutMap && null)"),
"Linux keyboard spoof must be inert"
);
}
}