pub mod benchmark;
pub mod validators;
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::pool::BrowserPool;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationTarget {
CreepJs,
BrowserScan,
FingerprintJs,
Kasada,
Cloudflare,
Akamai,
DataDome,
PerimeterX,
}
impl ValidationTarget {
#[must_use]
pub const fn url(self) -> &'static str {
match self {
Self::CreepJs => "https://abrahamjuliot.github.io/creepjs/",
Self::BrowserScan => "https://www.browserscan.net/",
Self::FingerprintJs => "https://fingerprint.com/demo/",
Self::Kasada => "https://www.wizzair.com/",
Self::Cloudflare => "https://www.cloudflare.com/",
Self::Akamai => "https://www.fedex.com/",
Self::DataDome => "https://datadome.co/",
Self::PerimeterX => "https://www.humansecurity.com/",
}
}
#[must_use]
pub const fn is_ci_safe(self) -> bool {
matches!(self, Self::CreepJs | Self::BrowserScan)
}
#[must_use]
pub const fn all() -> &'static [Self] {
&[
Self::CreepJs,
Self::BrowserScan,
Self::FingerprintJs,
Self::Kasada,
Self::Cloudflare,
Self::Akamai,
Self::DataDome,
Self::PerimeterX,
]
}
#[must_use]
pub const fn tier1() -> &'static [Self] {
&[Self::CreepJs, Self::BrowserScan]
}
}
impl fmt::Display for ValidationTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::CreepJs => "CreepJS",
Self::BrowserScan => "BrowserScan",
Self::FingerprintJs => "FingerprintJS",
Self::Kasada => "Kasada",
Self::Cloudflare => "Cloudflare",
Self::Akamai => "Akamai",
Self::DataDome => "DataDome",
Self::PerimeterX => "PerimeterX",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub target: ValidationTarget,
pub passed: bool,
pub score: Option<f64>,
pub details: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot: Option<Vec<u8>>,
#[serde(with = "duration_secs")]
pub elapsed: Duration,
}
impl ValidationResult {
#[must_use]
pub fn failed(target: ValidationTarget, reason: &str) -> Self {
Self {
target,
passed: false,
score: None,
details: HashMap::from([("error".to_string(), reason.to_string())]),
screenshot: None,
elapsed: Duration::ZERO,
}
}
}
mod duration_secs {
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub(super) fn serialize<S>(d: &Duration, ser: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
d.as_secs_f64().serialize(ser)
}
pub(super) fn deserialize<'de, D>(de: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
f64::deserialize(de).map(Duration::from_secs_f64)
}
}
pub struct ValidationSuite;
impl ValidationSuite {
pub async fn run_all(
pool: &Arc<BrowserPool>,
targets: &[ValidationTarget],
) -> Vec<ValidationResult> {
let mut results = Vec::with_capacity(targets.len());
for &target in targets {
results.push(Self::run_one(pool, target).await);
}
results
}
pub async fn run_one(pool: &Arc<BrowserPool>, target: ValidationTarget) -> ValidationResult {
match target {
ValidationTarget::CreepJs => validators::run_creepjs(pool).await,
ValidationTarget::BrowserScan => validators::run_browserscan(pool).await,
ValidationTarget::Kasada => validators::run_kasada(pool).await,
ValidationTarget::Cloudflare => validators::run_cloudflare(pool).await,
ValidationTarget::Akamai => validators::run_akamai(pool).await,
ValidationTarget::FingerprintJs => ValidationResult::failed(
target,
"FingerprintJS Pro validation requires a Pro account — not automated",
),
ValidationTarget::DataDome => ValidationResult::failed(
target,
"DataDome validation requires a Pro account — not automated",
),
ValidationTarget::PerimeterX => ValidationResult::failed(
target,
"PerimeterX validation requires a Pro account — not automated",
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn result_serde_round_trip() {
let original = ValidationResult {
target: ValidationTarget::CreepJs,
passed: true,
score: Some(0.92),
details: HashMap::from([("trust_score".to_string(), "92%".to_string())]),
screenshot: None,
elapsed: Duration::from_millis(3800),
};
let json_result = serde_json::to_string(&original);
assert!(json_result.is_ok(), "serialize failed: {json_result:?}");
let Ok(json) = json_result else {
return;
};
let decoded_result: Result<ValidationResult, _> = serde_json::from_str(&json);
assert!(
decoded_result.is_ok(),
"deserialize failed: {decoded_result:?}"
);
let Ok(decoded) = decoded_result else {
return;
};
assert_eq!(decoded.target, original.target);
assert_eq!(decoded.passed, original.passed);
assert!(decoded.score.is_some(), "missing score in decoded result");
let Some(score) = decoded.score else {
return;
};
assert!((score - 0.92_f64).abs() < 1e-9);
let trust_score = decoded.details.get("trust_score");
assert_eq!(trust_score, Some(&"92%".to_string()));
assert!((decoded.elapsed.as_secs_f64() - 3.8_f64).abs() < 1e-6);
}
#[test]
fn all_targets_covered() {
let all = ValidationTarget::all();
assert_eq!(all.len(), 8, "all() must cover all 8 variants");
for t in all {
let url = t.url();
assert!(url.starts_with("https://"), "URL for {t} must use HTTPS");
}
}
#[test]
fn tier1_is_ci_safe() {
let tier1 = ValidationTarget::tier1();
assert_eq!(tier1.len(), 2);
for t in tier1 {
assert!(t.is_ci_safe(), "{t} must be CI-safe");
}
}
#[test]
fn tier2_not_ci_safe() {
let tier2 = [
ValidationTarget::Kasada,
ValidationTarget::Cloudflare,
ValidationTarget::Akamai,
];
for t in tier2 {
assert!(!t.is_ci_safe(), "{t} must NOT be CI-safe");
}
}
#[test]
fn display_names() {
assert_eq!(ValidationTarget::CreepJs.to_string(), "CreepJS");
assert_eq!(ValidationTarget::BrowserScan.to_string(), "BrowserScan");
assert_eq!(ValidationTarget::FingerprintJs.to_string(), "FingerprintJS");
assert_eq!(ValidationTarget::Kasada.to_string(), "Kasada");
assert_eq!(ValidationTarget::Cloudflare.to_string(), "Cloudflare");
assert_eq!(ValidationTarget::Akamai.to_string(), "Akamai");
assert_eq!(ValidationTarget::DataDome.to_string(), "DataDome");
assert_eq!(ValidationTarget::PerimeterX.to_string(), "PerimeterX");
}
#[tokio::test]
#[ignore = "requires network connectivity and a running Chrome binary"]
async fn live_creepjs_returns_score() {
use crate::BrowserConfig;
use crate::pool::BrowserPool;
let pool_result = BrowserPool::new(BrowserConfig::default()).await;
assert!(pool_result.is_ok(), "pool init failed");
let Ok(pool) = pool_result else {
return;
};
let result = ValidationSuite::run_one(&pool, ValidationTarget::CreepJs).await;
assert!(
result.score.is_some(),
"CreepJS should return a score: {:?}",
result.details
);
}
#[tokio::test]
#[ignore = "requires network connectivity and a running Chrome binary"]
async fn live_browserscan_returns_percentage() {
use crate::BrowserConfig;
use crate::pool::BrowserPool;
let pool_result = BrowserPool::new(BrowserConfig::default()).await;
assert!(pool_result.is_ok(), "pool init failed");
let Ok(pool) = pool_result else {
return;
};
let result = ValidationSuite::run_one(&pool, ValidationTarget::BrowserScan).await;
assert!(
result.score.is_some(),
"BrowserScan should return a score: {:?}",
result.details
);
}
#[tokio::test]
#[ignore = "requires network connectivity and a running Chrome binary"]
async fn tier1_non_regression_against_optional_baseline() {
use crate::BrowserConfig;
use crate::pool::BrowserPool;
use std::sync::Arc;
let pool_result = BrowserPool::new(BrowserConfig::default()).await;
assert!(pool_result.is_ok(), "pool init failed");
let Ok(pool) = pool_result else {
return;
};
let pool = Arc::new(pool);
let results = ValidationSuite::run_all(&pool, ValidationTarget::tier1()).await;
assert_eq!(results.len(), 2, "tier1 should execute exactly two targets");
for result in &results {
assert!(
result.score.is_some(),
"{} should return a score for baseline comparison: {:?}",
result.target,
result.details
);
}
let creepjs_baseline = std::env::var("STYGIAN_TIER1_BASELINE_CREEPJS")
.ok()
.and_then(|v| v.parse::<f64>().ok());
let browserscan_baseline = std::env::var("STYGIAN_TIER1_BASELINE_BROWSERSCAN")
.ok()
.and_then(|v| v.parse::<f64>().ok());
for result in results {
let Some(score) = result.score else {
continue;
};
match result.target {
ValidationTarget::CreepJs => {
if let Some(baseline) = creepjs_baseline {
assert!(
score >= baseline,
"CreepJS score regressed: score={score:.4}, baseline={baseline:.4}, details={:?}",
result.details
);
}
}
ValidationTarget::BrowserScan => {
if let Some(baseline) = browserscan_baseline {
assert!(
score >= baseline,
"BrowserScan score regressed: score={score:.4}, baseline={baseline:.4}, details={:?}",
result.details
);
}
}
_ => {}
}
}
}
#[tokio::test]
#[ignore = "requires network connectivity and a running Chrome binary"]
async fn live_about_blank_webgl_vendor_not_swiftshader() {
use crate::BrowserConfig;
use crate::WaitUntil;
use crate::config::StealthLevel;
use crate::diagnostic::CheckId;
use crate::pool::BrowserPool;
let config = BrowserConfig::builder()
.headless(true)
.stealth_level(StealthLevel::Advanced)
.build();
let pool_result = BrowserPool::new(config).await;
assert!(pool_result.is_ok(), "pool init failed");
let Ok(pool) = pool_result else {
return;
};
let handle_result = pool.acquire().await;
assert!(handle_result.is_ok(), "acquire failed");
let Ok(handle) = handle_result else {
return;
};
let browser = handle.browser();
assert!(browser.is_some(), "browser handle no longer valid");
let Some(browser) = browser else {
handle.release().await;
return;
};
let page_result = browser.new_page().await;
assert!(page_result.is_ok(), "new_page failed");
let Ok(mut page) = page_result else {
handle.release().await;
return;
};
let nav_result = page
.navigate(
"about:blank",
WaitUntil::DomContentLoaded,
Duration::from_secs(20),
)
.await;
assert!(nav_result.is_ok(), "navigate failed: {nav_result:?}");
let verify_result = page.verify_stealth().await;
assert!(
verify_result.is_ok(),
"verify_stealth failed: {verify_result:?}"
);
let Ok(report) = verify_result else {
let _ = page.close().await;
handle.release().await;
return;
};
let webgl_check = report.checks.iter().find(|c| c.id == CheckId::WebGlVendor);
assert!(
webgl_check.is_some(),
"web_gl_vendor check missing from report"
);
let Some(webgl_check) = webgl_check else {
let _ = page.close().await;
handle.release().await;
return;
};
assert!(
webgl_check.passed,
"web_gl_vendor failed: {}",
webgl_check.details
);
assert!(
!webgl_check.details.contains("SwiftShader"),
"web_gl_vendor details still expose SwiftShader: {}",
webgl_check.details
);
let _ = page.close().await;
handle.release().await;
}
#[tokio::test]
#[ignore = "requires network connectivity and a running Chrome binary"]
async fn live_kasada_wizzair_not_blocked() {
use crate::BrowserConfig;
use crate::pool::BrowserPool;
let pool_result = BrowserPool::new(BrowserConfig::default()).await;
assert!(pool_result.is_ok(), "pool init failed");
let Ok(pool) = pool_result else {
return;
};
let result = ValidationSuite::run_one(&pool, ValidationTarget::Kasada).await;
assert!(
result.passed,
"WizzAir should not block us: {:?}",
result.details
);
}
}