use super::*;
#[test]
fn shannon_entropy_zero_for_constant_string() {
let bytes = b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
assert!(shannon_entropy(bytes) < 0.1);
}
#[test]
fn shannon_entropy_high_for_random_base64url() {
let bytes = b"aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ";
assert!(shannon_entropy(bytes) > 5.0);
}
#[test]
fn ks_d_uniform_low_for_balanced_base64url() {
let bytes: Vec<u8> = (0..240)
.map(|i| match i % 6 {
0 => b'A' + (i % 26) as u8,
1 => b'a' + (i % 26) as u8,
2 => b'0' + (i % 10) as u8,
3 => b'5' + (i % 5) as u8,
4 => b'-',
_ => b'_',
})
.collect();
let d = ks_d_against_uniform_base64(&bytes);
assert!(
d < 0.5,
"KS-D should be < 0.5 for balanced base64url, got {d}"
);
}
#[test]
fn ks_d_uniform_high_for_repetitive_input() {
let bytes = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let d = ks_d_against_uniform_base64(bytes);
assert!(d > 0.5, "KS-D should be high for repetitive, got {d}");
}
#[test]
fn markov_transition_score_higher_for_real_token() {
let real = b"aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ";
let decoy = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let real_score = markov_transition_score(real);
let decoy_score = markov_transition_score(decoy);
assert!(
real_score >= decoy_score,
"real {real_score} should be >= decoy {decoy_score}"
);
}
#[test]
fn run_length_score_high_for_random_input() {
let s = b"aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ";
assert!(run_length_score(s) > 0.7);
}
#[test]
fn run_length_score_low_for_repeated() {
let s = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
assert!(run_length_score(s) < 0.5);
}
#[test]
fn compressibility_distinguishes_random_vs_repetitive() {
let random = b"aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ";
let repetitive = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
assert!(
compressibility_score(random) > compressibility_score(repetitive),
"random {} should be > repetitive {}",
compressibility_score(random),
compressibility_score(repetitive)
);
}
#[test]
fn ascii_concentration_high_for_typical_token() {
let s = b"aB3xY7zQ9mK2wL5";
assert!(ascii_concentration(s) > 0.9);
}
#[test]
fn dot_segment_variation_high_for_jwt_shape() {
let token = "aaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbb.cccccccccccccccccccc";
assert!(dot_segment_variation(token) > 0.7);
}
#[test]
fn classify_rejects_empty_token() {
assert_eq!(classify("", "turnstile"), DecoyVerdict::Decoy);
}
#[test]
fn classify_rejects_obvious_decoy() {
assert_eq!(classify("DUMMY", "turnstile"), DecoyVerdict::Decoy);
assert_eq!(classify("ok", "turnstile"), DecoyVerdict::Decoy);
}
#[test]
fn classify_accepts_high_entropy_long_token() {
let token = "0.aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ8tW1nB4mE7sD0xL3kJ6hG9fR2qV5yU8cP1aB4eX7zM0nQ3kL6jH9pR2tV5wY8xC1dF4gN7eM0lJ3kS6aZbY9cV2uI5oP8rQ1tW4nB7mE0sD3xL6kJ9hG2fR5qV8yU1cP4aB7eX0zM3nQ";
let v = classify(token, "turnstile");
assert_ne!(v, DecoyVerdict::Decoy, "real-shape token rejected: {v:?}");
}
#[test]
fn classify_rejects_padded_low_entropy_decoy() {
let s = "0.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
assert_eq!(classify(s, "turnstile"), DecoyVerdict::Decoy);
}
#[test]
fn feature_vector_score_in_unit_interval() {
let max = FeatureVector {
entropy: 1.0,
ks_uniform: 1.0,
markov: 1.0,
chi_sq_uniform: 1.0,
run_length: 1.0,
compressibility: 1.0,
ascii_concentration: 1.0,
dot_segment_cv: 1.0,
hex_ratio: 1.0,
bigram_coverage: 1.0,
length_match: 1.0,
};
assert!(max.score() >= 0.99);
let min = FeatureVector {
entropy: 0.0,
ks_uniform: 0.0,
markov: 0.0,
chi_sq_uniform: 0.0,
run_length: 0.0,
compressibility: 0.0,
ascii_concentration: 0.0,
dot_segment_cv: 0.0,
hex_ratio: 0.0,
bigram_coverage: 0.0,
length_match: 0.0,
};
assert!(min.score() <= 0.01);
}
#[test]
fn vendor_profile_for_each_known_vendor() {
for v in &["turnstile", "hcaptcha", "recaptcha-v2", "recaptcha-v3"] {
assert!(profile_for(v).is_some());
}
assert!(profile_for("unknown_vendor").is_none());
}
#[test]
fn decoy_detector_todo_lists_concrete_items() {
for item in DECOY_DETECTOR_TODO {
let s = item.to_lowercase();
assert!(
!s.contains("consider")
&& !s.contains("investigate")
&& !s.contains("maybe")
&& !s.contains("could"),
"TODO must be concrete: {item}"
);
}
}
#[test]
fn scale_classify_10k_random_high_entropy_real_tokens_mostly_real() {
use rand::{rngs::StdRng, Rng, SeedableRng};
let mut rng = StdRng::seed_from_u64(0xC4FF_5C0E);
const N: usize = 10_000;
let mut real_count = 0;
for _ in 0..N {
let prefix = if rng.gen_bool(0.5) { "0." } else { "1." };
let body: String = (0..320)
.map(|_| {
let idx: u8 = rng.gen_range(0..64);
match idx {
0..=25 => (b'A' + idx) as char,
26..=51 => (b'a' + (idx - 26)) as char,
52..=61 => (b'0' + (idx - 52)) as char,
62 => '-',
_ => '_',
}
})
.collect();
let token = format!("{prefix}{body}");
if classify(&token, "turnstile") != DecoyVerdict::Decoy {
real_count += 1;
}
}
let rate = (real_count as f32) / (N as f32);
assert!(
rate >= 0.75,
"real-shape token acceptance rate {:.2} below 0.75; saw {} real / {} total",
rate,
real_count,
N
);
}
#[test]
fn scale_classify_10k_random_constant_decoys_mostly_decoy() {
const N: usize = 10_000;
let mut decoy_count = 0;
for i in 0..N {
let token = format!("0.{}", "a".repeat(220 + (i % 50)));
if classify(&token, "turnstile") == DecoyVerdict::Decoy {
decoy_count += 1;
}
}
let rate = (decoy_count as f32) / (N as f32);
assert!(rate >= 0.99, "decoy detection rate {:.2} below 0.99", rate);
}
#[test]
fn scale_extract_features_10k_calls_finishes_quickly() {
use std::time::Instant;
let token = "0.aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ8tW1nB4mE7sD0xL3kJ6hG9fR2qV5yU8cP1aB4eX7zM0nQ3kL6jH9pR2tV5wY8xC1dF4gN7eM0lJ3kS6aZbY9cV2uI5oP8rQ1tW4nB7mE0sD3xL6kJ9hG2fR5qV8yU1cP4aB";
let t0 = Instant::now();
for _ in 0..10_000 {
let _ = extract_features(token, "turnstile");
}
let elapsed = t0.elapsed();
assert!(
elapsed.as_secs() < 5,
"10k extract_features took {:?}; budget 5s",
elapsed
);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 10_000, .. proptest::test_runner::Config::default()
})]
#[test]
fn prop_classify_never_panics(s in proptest::collection::vec(0u8..=255, 0..400)) {
let token = String::from_utf8_lossy(&s).to_string();
for vendor in ["turnstile", "hcaptcha", "recaptcha-v2", "recaptcha-v3", "geetest"] {
let _ = classify(&token, vendor);
}
}
#[test]
fn prop_empty_token_always_decoy(vendor in "turnstile|hcaptcha|recaptcha-v2|recaptcha-v3") {
assert_eq!(classify("", &vendor), DecoyVerdict::Decoy);
}
#[test]
fn prop_short_token_under_min_always_decoy(len in 0usize..40) {
let token: String = "a".repeat(len);
assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
}
#[test]
fn prop_features_in_unit_interval(s in proptest::collection::vec(b'a'..=b'z', 0..400)) {
let token = String::from_utf8(s).unwrap();
let f = extract_features(&token, "turnstile");
for value in [
f.entropy, f.ks_uniform, f.markov, f.chi_sq_uniform,
f.run_length, f.compressibility, f.ascii_concentration,
f.dot_segment_cv, f.hex_ratio, f.bigram_coverage, f.length_match,
] {
assert!(value >= 0.0 && value <= 1.0, "feature out of [0,1]: {value}");
}
}
#[test]
fn prop_score_in_unit_interval(s in proptest::collection::vec(0u8..=255, 0..400)) {
let token = String::from_utf8_lossy(&s).to_string();
let f = extract_features(&token, "turnstile");
let score = f.score();
assert!(score >= 0.0 && score <= 1.0, "score out of [0,1]: {score}");
}
#[test]
fn prop_classify_monotone_in_length_for_same_alphabet(
shorter in proptest::collection::vec(b'a'..=b'z', 0..100),
longer_padding in proptest::collection::vec(b'a'..=b'z', 0..200),
) {
let shorter_s: String = shorter.iter().map(|b| *b as char).collect();
let mut longer_s = shorter_s.clone();
for c in &longer_padding {
longer_s.push(*c as char);
}
let s_verdict = classify(&shorter_s, "turnstile");
let l_verdict = classify(&longer_s, "turnstile");
if shorter_s.len() < 200 {
assert_eq!(s_verdict, DecoyVerdict::Decoy,
"short token must be decoy regardless");
}
let _ = l_verdict;
}
#[test]
fn prop_high_entropy_long_string_not_always_decoy(
payload in proptest::collection::vec(b'a'..=b'z', 200..400),
) {
let token: String = payload.iter().map(|b| *b as char).collect();
let v = classify(&token, "turnstile");
let _ = matches!(v, DecoyVerdict::Real | DecoyVerdict::Borderline | DecoyVerdict::Decoy);
}
#[test]
fn prop_runlength_score_inverts_runlength(len in 5usize..200) {
let s = vec![b'a'; len];
assert!(run_length_score(&s) < 0.5);
}
#[test]
fn prop_shannon_entropy_bounded(bytes in proptest::collection::vec(0u8..=255, 0..1024)) {
let h = shannon_entropy(&bytes);
assert!(h >= 0.0);
assert!(h <= 8.0001, "entropy {h} should be <= 8.0");
}
#[test]
fn prop_ks_d_in_unit_interval(bytes in proptest::collection::vec(0u8..=255, 0..1024)) {
let d = ks_d_against_uniform_base64(&bytes);
assert!(d >= 0.0 && d <= 1.0);
}
}