use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct FeatureVector {
pub entropy: f32,
pub ks_uniform: f32,
pub markov: f32,
pub chi_sq_uniform: f32,
pub run_length: f32,
pub compressibility: f32,
pub ascii_concentration: f32,
pub dot_segment_cv: f32,
pub hex_ratio: f32,
pub bigram_coverage: f32,
pub length_match: f32,
}
impl FeatureVector {
pub fn score(&self) -> f32 {
let s = 0.18 * self.entropy
+ 0.12 * self.ks_uniform
+ 0.14 * self.markov
+ 0.08 * self.chi_sq_uniform
+ 0.07 * self.run_length
+ 0.08 * self.compressibility
+ 0.05 * self.ascii_concentration
+ 0.06 * self.dot_segment_cv
+ 0.04 * self.hex_ratio
+ 0.10 * self.bigram_coverage
+ 0.08 * self.length_match;
s.clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DecoyVerdict {
Real,
Borderline,
Decoy,
}
#[derive(Debug, Clone, Copy)]
pub struct VendorProfile {
pub vendor: &'static str,
pub mean_length: f32,
pub min_length: usize,
}
pub const VENDOR_PROFILES: &[VendorProfile] = &[
VendorProfile {
vendor: "turnstile",
mean_length: 340.0,
min_length: 200,
},
VendorProfile {
vendor: "hcaptcha",
mean_length: 350.0,
min_length: 80,
},
VendorProfile {
vendor: "recaptcha-v2",
mean_length: 1500.0,
min_length: 200,
},
VendorProfile {
vendor: "recaptcha-v3",
mean_length: 750.0,
min_length: 200,
},
VendorProfile {
vendor: "recaptcha-enterprise",
mean_length: 1500.0,
min_length: 200,
},
VendorProfile {
vendor: "geetest",
mean_length: 200.0,
min_length: 60,
},
VendorProfile {
vendor: "arkose",
mean_length: 240.0,
min_length: 60,
},
VendorProfile {
vendor: "datadome",
mean_length: 220.0,
min_length: 40,
},
VendorProfile {
vendor: "aws_waf",
mean_length: 280.0,
min_length: 60,
},
VendorProfile {
vendor: "akamai",
mean_length: 320.0,
min_length: 80,
},
VendorProfile {
vendor: "perimeterx",
mean_length: 200.0,
min_length: 60,
},
];
pub fn profile_for(vendor: &str) -> Option<&'static VendorProfile> {
VENDOR_PROFILES.iter().find(|p| p.vendor == vendor)
}
pub fn extract_features(token: &str, vendor: &str) -> FeatureVector {
let bytes = token.as_bytes();
let len = bytes.len() as f32;
let entropy = shannon_entropy(bytes) / 8.0; let ks_uniform = 1.0 - ks_d_against_uniform_base64(bytes);
let markov = markov_transition_score(bytes);
let chi_sq_uniform = chi_squared_normalised(bytes);
let run_length = run_length_score(bytes);
let compressibility = compressibility_score(bytes);
let ascii_concentration = ascii_concentration(bytes);
let dot_segment_cv = dot_segment_variation(token);
let hex_ratio = hex_ratio(bytes);
let bigram_coverage = bigram_coverage(bytes);
let length_match = if let Some(p) = profile_for(vendor) {
if len <= 0.0 {
0.0
} else {
(1.0 - (len - p.mean_length).abs() / p.mean_length).clamp(0.0, 1.0)
}
} else {
0.5
};
FeatureVector {
entropy,
ks_uniform,
markov,
chi_sq_uniform,
run_length,
compressibility,
ascii_concentration,
dot_segment_cv,
hex_ratio,
bigram_coverage,
length_match,
}
}
pub fn classify(token: &str, vendor: &str) -> DecoyVerdict {
if token.is_empty() {
return DecoyVerdict::Decoy;
}
if let Some(p) = profile_for(vendor) {
if token.len() < p.min_length {
return DecoyVerdict::Decoy;
}
}
let dot_count = token.bytes().filter(|b| *b == b'.').count();
let too_many_dots = match vendor {
"recaptcha_v2" | "recaptcha_v3" | "recaptcha-v2" | "recaptcha-v3" => dot_count > 4,
"hcaptcha" => dot_count > 4,
_ => false,
};
if too_many_dots {
return DecoyVerdict::Decoy;
}
let features = extract_features(token, vendor);
let score = features.score();
if score >= 0.70 {
DecoyVerdict::Real
} else if score >= 0.40 {
DecoyVerdict::Borderline
} else {
DecoyVerdict::Decoy
}
}
pub fn shannon_entropy(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 h = 0.0f32;
for f in freq.iter() {
if *f == 0 {
continue;
}
let p = (*f as f32) / len;
h -= p * p.log2();
}
h
}
pub fn ks_d_against_uniform_base64(bytes: &[u8]) -> f32 {
if bytes.is_empty() {
return 1.0;
}
let mut counts = [0u32; 64];
let mut total = 0u32;
for b in bytes {
if let Some(idx) = base64url_index(*b) {
counts[idx] += 1;
total += 1;
}
}
if total == 0 {
return 1.0;
}
let mut emp_cdf = 0.0f32;
let mut max_d = 0.0f32;
let total_f = total as f32;
for (i, c) in counts.iter().enumerate() {
emp_cdf += (*c as f32) / total_f;
let uniform_cdf = ((i + 1) as f32) / 64.0;
let d = (emp_cdf - uniform_cdf).abs();
if d > max_d {
max_d = d;
}
}
max_d.clamp(0.0, 1.0)
}
fn base64url_index(b: u8) -> Option<usize> {
match b {
b'A'..=b'Z' => Some((b - b'A') as usize),
b'a'..=b'z' => Some(26 + (b - b'a') as usize),
b'0'..=b'9' => Some(52 + (b - b'0') as usize),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
pub fn markov_transition_score(bytes: &[u8]) -> f32 {
if bytes.len() < 2 {
return 0.5;
}
const REAL_TRANS: [[f32; 4]; 4] = [
[0.10, 0.35, 0.25, 0.30],
[0.30, 0.20, 0.25, 0.25],
[0.25, 0.25, 0.20, 0.30],
[0.30, 0.30, 0.30, 0.10],
];
let mut log_sum = 0.0f64;
let mut n = 0;
let mut prev = class_of(bytes[0]);
for b in &bytes[1..] {
let cur = class_of(*b);
let p = REAL_TRANS[prev][cur].max(1e-6);
log_sum += (p as f64).ln();
prev = cur;
n += 1;
}
if n == 0 {
return 0.5;
}
let mean_log_p = log_sum / (n as f64);
let p_mean = mean_log_p.exp() as f32;
(p_mean * 2.0).clamp(0.0, 1.0)
}
fn class_of(b: u8) -> usize {
match b {
b'a' | b'e' | b'i' | b'o' | b'u' | b'A' | b'E' | b'I' | b'O' | b'U' => 0,
b'a'..=b'z' | b'A'..=b'Z' => 1,
b'0'..=b'9' => 2,
_ => 3,
}
}
pub fn chi_squared_normalised(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 expected = len / 256.0;
let mut chi2 = 0.0f32;
for f in freq.iter() {
let diff = (*f as f32) - expected;
chi2 += diff * diff / expected.max(1e-6);
}
let normalised = (chi2 / len).min(50.0);
1.0 - (normalised / 50.0).clamp(0.0, 1.0)
}
pub fn run_length_score(bytes: &[u8]) -> f32 {
if bytes.is_empty() {
return 0.0;
}
let mut run_hist = [0u32; 16];
let mut prev = bytes[0];
let mut cur_run = 1u32;
let mut runs = 0u32;
for b in &bytes[1..] {
if *b == prev {
cur_run += 1;
} else {
let idx = (cur_run.min(15)) as usize;
run_hist[idx] += 1;
runs += 1;
cur_run = 1;
prev = *b;
}
}
let idx = (cur_run.min(15)) as usize;
run_hist[idx] += 1;
runs += 1;
if runs == 0 {
return 0.0;
}
let p1 = (run_hist[1] as f32) / (runs as f32);
p1.clamp(0.0, 1.0)
}
pub fn compressibility_score(bytes: &[u8]) -> f32 {
if bytes.len() < 16 {
return 0.5;
}
let approx = lz_estimate(bytes);
let ratio = (approx as f32) / (bytes.len() as f32);
ratio.clamp(0.0, 1.0)
}
fn lz_estimate(bytes: &[u8]) -> usize {
let n = bytes.len();
let mut emitted = 0usize;
let mut i = 0usize;
let window = 64;
while i < n {
let start = i.saturating_sub(window);
let mut best_len = 0usize;
let mut j = start;
while j < i {
let mut k = 0usize;
while i + k < n && bytes[j + k] == bytes[i + k] && k < 16 {
k += 1;
}
if k > best_len {
best_len = k;
}
j += 1;
}
if best_len < 3 {
emitted += 1;
i += 1;
} else {
emitted += 1;
i += best_len;
}
}
emitted
}
pub fn ascii_concentration(bytes: &[u8]) -> f32 {
if bytes.is_empty() {
return 0.0;
}
let printable = bytes.iter().filter(|&&b| (32u8..=126).contains(&b)).count();
(printable as f32) / (bytes.len() as f32)
}
pub fn dot_segment_variation(token: &str) -> f32 {
let segs: Vec<usize> = token.split('.').map(|s| s.len()).collect();
if segs.len() < 2 {
return 0.5;
}
let n = segs.len() as f32;
let mean = segs.iter().map(|&l| l as f32).sum::<f32>() / n;
if mean <= 0.0 {
return 0.0;
}
let var = segs
.iter()
.map(|&l| {
let d = (l as f32) - mean;
d * d
})
.sum::<f32>()
/ n;
let cv = var.sqrt() / mean;
if cv <= 0.7 {
1.0
} else if cv < 1.5 {
1.0 - (cv - 0.7) / 0.8
} else {
0.0
}
.clamp(0.0, 1.0)
}
pub fn hex_ratio(bytes: &[u8]) -> f32 {
if bytes.is_empty() {
return 0.0;
}
let mut hex = 0usize;
let mut bu = 0usize;
for b in bytes {
if b.is_ascii_hexdigit() {
hex += 1;
}
if base64url_index(*b).is_some() {
bu += 1;
}
}
if bu == 0 {
return 0.0;
}
let r = (hex as f32) / (bu as f32);
if (0.2..=0.6).contains(&r) {
1.0
} else if r > 0.6 {
1.0 - (r - 0.6) / 0.4
} else {
r / 0.2
}
.clamp(0.0, 1.0)
}
pub fn bigram_coverage(bytes: &[u8]) -> f32 {
const REAL_BIGRAMS: &[[u8; 2]] = &[
*b"aB", *b"Bc", *b"cD", *b"De", *b"eF", *b"Fg", *b"gH", *b"Hi", *b"iJ", *b"Jk", *b"kL",
*b"Lm", *b"Mn", *b"No", *b"Op", *b"Pq", *b"qR", *b"Rs", *b"St", *b"Tu", *b"Uv", *b"Vw",
*b"Wx", *b"Xy", *b"Yz", *b"Z0", *b"01", *b"12", *b"23", *b"34", *b"45", *b"56",
];
if bytes.len() < 2 {
return 0.0;
}
let mut hits = 0u32;
let total = (bytes.len() - 1) as u32;
for i in 0..bytes.len() - 1 {
let pair = [bytes[i], bytes[i + 1]];
if REAL_BIGRAMS.contains(&pair) {
hits += 1;
}
}
let mut score = (hits as f32) / (total as f32);
if score > 0.05 {
score = (score - 0.05) / 0.10;
} else {
score = 0.0;
}
score.clamp(0.0, 1.0)
}
pub const DECOY_DETECTOR_TODO: &[&str] = &[
"Replace hand-tuned linear weights with logistic-regression \
weights fit from a labelled corpus (build `tests/decoy_corpus/` \
with 10k labelled real + decoy tokens).",
"Replace 4-class Markov transition matrix with 16-class \
transition matrix sampled from real corpus per vendor.",
"Replace 32-bigram coverage list with vendor-specific top-N \
bigram tables.",
"Add a 12th feature: position-weighted-bigram score (real tokens \
have stable prefix shapes vs random middle).",
"Add 13th feature: vendor-prefix conformance (Turnstile `0.`/`1.`, \
hCaptcha `P0_`/`P1_`).",
"Add 14th feature: post-decode JSON parseability (some vendor \
tokens are JWT-style base64-encoded JSON).",
"Train ROC-AUC ≥ 0.99 across vendors against the labelled corpus.",
"Add a calibration test: detector verdict ↔ live-vendor accept \
rate alignment > 0.95 (when bench is wired against real vendor).",
];
#[cfg(test)]
mod tests {
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);
}
}
}