use captchaforge::solver::token_shapes::{for_vendor, TokenOracle, TokenShape};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
struct VendorContract {
vendor: VendorMeta,
positives: Positives,
negatives: Negatives,
evasions: Evasions,
#[allow(dead_code)]
cross_file: CrossFile,
cve_replay: CveReplay,
property: PropertyContract,
differential: DifferentialContract,
perf: PerfContract,
scale: ScaleContract,
e2e_cli: E2eCliContract,
}
#[derive(Debug, Deserialize)]
struct VendorMeta {
#[allow(dead_code)]
name: String,
display_name: String,
captcha_type: String,
api_url: String,
#[serde(default)]
public_test_sitekeys: Vec<Sitekey>,
}
#[derive(Debug, Deserialize)]
struct Sitekey {
kind: String,
#[allow(dead_code)]
key: String,
}
#[derive(Debug, Deserialize)]
struct Positives {
sample_tokens: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct Negatives {
decoy_tokens: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct Evasions {
inputs: Vec<EvasionInput>,
}
#[derive(Debug, Deserialize)]
struct EvasionInput {
token: String,
classify: String, }
#[derive(Debug, Deserialize)]
struct CrossFile {
#[allow(dead_code)]
scenario: String,
}
#[derive(Debug, Deserialize)]
struct CveReplay {
cases: Vec<CveCase>,
}
#[derive(Debug, Deserialize)]
struct CveCase {
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
description: String,
token: String,
expect: String,
}
#[derive(Debug, Deserialize)]
struct PropertyContract {
min_length_for_plausible: usize,
runs: usize,
}
#[derive(Debug, Deserialize)]
struct DifferentialContract {
#[allow(dead_code)]
agreement_target: f32,
consensus: Vec<ConsensusCase>,
}
#[derive(Debug, Deserialize)]
struct ConsensusCase {
token: String,
verdict: String,
}
#[derive(Debug, Deserialize)]
struct PerfContract {
budget_us_per_classify: f32,
#[allow(dead_code)]
budget_us_per_solve_path: f32,
}
#[derive(Debug, Deserialize)]
struct ScaleContract {
batch_size: usize,
budget_seconds: f32,
}
#[derive(Debug, Deserialize)]
struct E2eCliContract {
#[allow(dead_code)]
detector_class: String,
#[allow(dead_code)]
expected_solve_methods: Vec<String>,
#[allow(dead_code)]
forbidden_methods: Vec<String>,
}
fn contract_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vendors")
}
fn load_contract(path: &Path) -> VendorContract {
let body = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read {}: {}", path.display(), e));
toml::from_str(&body).unwrap_or_else(|e| panic!("failed to parse {}: {}", path.display(), e))
}
fn all_contracts() -> Vec<(String, VendorContract)> {
let dir = contract_dir();
let mut out = Vec::new();
for entry in std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("failed to read {}: {}", dir.display(), e))
{
let entry = entry.expect("dir entry");
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("toml") {
let name = path.file_stem().unwrap().to_string_lossy().to_string();
out.push((name, load_contract(&path)));
}
}
assert!(
out.len() >= 10,
"vendor contract directory must hold at least 10 vendors; found {}",
out.len()
);
out.sort_by(|(a, _), (b, _)| a.cmp(b));
out
}
fn parse_shape(s: &str) -> TokenShape {
match s {
"plausible" => TokenShape::Plausible,
"suspect" => TokenShape::Suspect,
"decoy" => TokenShape::Decoy,
other => panic!("unknown TokenShape label: {other}"),
}
}
fn oracle_for(vendor: &str) -> Option<TokenOracle> {
let key = match vendor {
"turnstile" => "cloudflare-turnstile",
"hcaptcha" => "hcaptcha",
"recaptcha_v2" => "recaptcha-v2",
"recaptcha_v3" => "recaptcha-v3",
_ => return None,
};
for_vendor(key)
}
fn classify_synthetic(token: &str, min_len: usize) -> TokenShape {
if token.is_empty() || token.len() < min_len {
return TokenShape::Decoy;
}
if token
.chars()
.any(|c| matches!(c, '<' | '>' | ' ' | '\n' | '\t'))
{
return TokenShape::Decoy;
}
if !token.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
'.' | '-' | '_' | '=' | '&' | '@' | '~' | ':' | '|' | '+' | '/' | ';' | ','
)
}) {
return TokenShape::Decoy;
}
let entropy = synthetic_shannon(token.as_bytes());
if entropy < 3.5 {
return TokenShape::Decoy;
}
if entropy < 4.5 {
return TokenShape::Suspect;
}
TokenShape::Plausible
}
fn synthetic_shannon(bytes: &[u8]) -> f32 {
if bytes.is_empty() {
return 0.0;
}
let mut freq = [0u32; 256];
for b in bytes {
freq[*b as usize] += 1;
}
let len = bytes.len() as f32;
let mut entropy = 0.0f32;
for f in freq.iter() {
if *f == 0 {
continue;
}
let p = (*f as f32) / len;
entropy -= p * p.log2();
}
entropy
}
fn classify(vendor: &str, token: &str, min_len_for_synthetic: usize) -> TokenShape {
if let Some(o) = oracle_for(vendor) {
o.classify(token)
} else {
classify_synthetic(token, min_len_for_synthetic)
}
}
#[test]
fn positives_classify_as_plausible() {
for (name, c) in all_contracts() {
for tok in &c.positives.sample_tokens {
let shape = classify(&name, tok, c.property.min_length_for_plausible);
assert!(
matches!(shape, TokenShape::Plausible | TokenShape::Suspect),
"{}: positive sample classified as {:?}: {}",
c.vendor.display_name,
shape,
if tok.len() > 60 { &tok[..60] } else { tok }
);
}
}
}
#[test]
fn negatives_classify_as_decoy() {
for (name, c) in all_contracts() {
for tok in &c.negatives.decoy_tokens {
let shape = classify(&name, tok, c.property.min_length_for_plausible);
assert_eq!(
shape,
TokenShape::Decoy,
"{}: decoy sample {:?} classified as {:?}",
c.vendor.display_name,
tok,
shape
);
}
}
}
#[test]
fn evasions_match_declared_classification() {
for (name, c) in all_contracts() {
for ev in &c.evasions.inputs {
let want = parse_shape(&ev.classify);
let got = classify(&name, &ev.token, c.property.min_length_for_plausible);
let pass = match want {
TokenShape::Decoy => got == TokenShape::Decoy,
TokenShape::Plausible => matches!(got, TokenShape::Plausible | TokenShape::Suspect),
TokenShape::Suspect => matches!(got, TokenShape::Suspect | TokenShape::Plausible),
};
assert!(
pass,
"{}: evasion {:?} expected {:?}, got {:?}",
c.vendor.display_name, ev.token, want, got
);
}
}
}
#[test]
fn cross_file_scenarios_are_recognised() {
let recognised = ["token_capture_and_replay", "cookie_capture_and_replay"];
for (_, c) in all_contracts() {
assert!(
recognised.contains(&c.cross_file.scenario.as_str()),
"{}: cross_file.scenario {:?} is not recognised; valid: {:?}",
c.vendor.display_name,
c.cross_file.scenario,
recognised
);
}
}
#[test]
fn cve_replay_cases_classify_as_expected() {
for (name, c) in all_contracts() {
for case in &c.cve_replay.cases {
let want = parse_shape(&case.expect);
let got = classify(&name, &case.token, c.property.min_length_for_plausible);
assert_eq!(
got, want,
"{}: CVE replay {} expected {:?}, got {:?}",
c.vendor.display_name, case.id, want, got
);
}
}
}
#[test]
fn property_short_random_inputs_never_plausible() {
use rand::Rng;
let mut rng = rand::thread_rng();
for (name, c) in all_contracts() {
let runs = c.property.runs.min(5_000); let floor = c.property.min_length_for_plausible;
for _ in 0..runs {
let len = rng.gen_range(0..floor.max(1));
let s: String = (0..len)
.map(|_| {
let c: u8 = rng.gen_range(33..126);
c as char
})
.collect();
let got = classify(&name, &s, c.property.min_length_for_plausible);
assert_ne!(
got,
TokenShape::Plausible,
"{}: random short input {:?} classified as Plausible",
c.vendor.display_name,
s
);
}
}
}
#[test]
fn differential_consensus_tokens_agree() {
for (name, c) in all_contracts() {
for case in &c.differential.consensus {
let want = parse_shape(&case.verdict);
let got = classify(&name, &case.token, c.property.min_length_for_plausible);
let pass = match want {
TokenShape::Decoy => got == TokenShape::Decoy,
TokenShape::Plausible => matches!(got, TokenShape::Plausible | TokenShape::Suspect),
TokenShape::Suspect => matches!(got, TokenShape::Suspect | TokenShape::Plausible),
};
assert!(
pass,
"{}: differential consensus {:?} expected {:?}, got {:?}",
c.vendor.display_name, case.token, want, got
);
}
}
}
#[test]
fn perf_classify_under_budget() {
use std::time::Instant;
for (name, c) in all_contracts() {
for _ in 0..1000 {
let _ = classify(
&name,
"warmup-token-for-warmup-runs-only",
c.property.min_length_for_plausible,
);
}
let runs = 10_000;
let token = c
.positives
.sample_tokens
.first()
.cloned()
.unwrap_or_else(|| "x".repeat(60));
let t0 = Instant::now();
for _ in 0..runs {
let _ = classify(&name, &token, c.property.min_length_for_plausible);
}
let elapsed_us = t0.elapsed().as_micros() as f32 / runs as f32;
assert!(
elapsed_us < c.perf.budget_us_per_classify,
"{}: classify took {:.2}µs; budget {:.2}µs",
c.vendor.display_name,
elapsed_us,
c.perf.budget_us_per_classify
);
}
}
#[test]
fn scale_batch_classify_under_budget() {
use std::time::Instant;
for (name, c) in all_contracts() {
let token = c
.positives
.sample_tokens
.first()
.cloned()
.unwrap_or_else(|| "x".repeat(60));
let batch = c.scale.batch_size.min(50_000); let t0 = Instant::now();
let mut n_plausible = 0u64;
for _ in 0..batch {
if matches!(
classify(&name, &token, c.property.min_length_for_plausible),
TokenShape::Plausible | TokenShape::Suspect
) {
n_plausible += 1;
}
}
let elapsed = t0.elapsed().as_secs_f32();
assert!(
n_plausible >= (batch as u64) / 2,
"{}: scale batch produced too few plausibles ({}/{})",
c.vendor.display_name,
n_plausible,
batch
);
assert!(
elapsed < c.scale.budget_seconds,
"{}: scale batch took {:.2}s; budget {:.2}s",
c.vendor.display_name,
elapsed,
c.scale.budget_seconds
);
}
}
#[test]
fn e2e_cli_contracts_are_internally_consistent() {
for (_, c) in all_contracts() {
for forbidden in &c.e2e_cli.forbidden_methods {
assert!(
!c.e2e_cli.expected_solve_methods.contains(forbidden),
"{}: method {} listed as both expected and forbidden",
c.vendor.display_name,
forbidden
);
}
assert!(
!c.vendor.captcha_type.is_empty(),
"{}: captcha_type empty",
c.vendor.display_name
);
assert!(
c.vendor.api_url.starts_with("https://"),
"{}: api_url not https: {}",
c.vendor.display_name,
c.vendor.api_url
);
let recognised_kinds = ["autopass", "block", "force_interactive"];
for sk in &c.vendor.public_test_sitekeys {
assert!(
recognised_kinds.contains(&sk.kind.as_str()),
"{}: unrecognised sitekey kind {}",
c.vendor.display_name,
sk.kind
);
}
}
}
#[test]
fn every_shipped_vendor_has_a_contract() {
let contracts = all_contracts();
let names: Vec<String> = contracts.iter().map(|(n, _)| n.clone()).collect();
let required = [
"turnstile",
"hcaptcha",
"recaptcha_v2",
"recaptcha_v3",
"arkose",
"datadome",
"aws_waf",
"akamai",
"perimeterx",
"geetest",
];
for r in required {
assert!(
names.iter().any(|n| n == r),
"vendor {r} ships in src/solver/vendors/ but has no contract TOML in tests/vendors/"
);
}
}
#[test]
fn contract_dir_holds_at_least_ten_vendors() {
assert!(all_contracts().len() >= 10);
}