use super::*;
const REAL_TURNSTILE_SAMPLE: &str = "0.aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ8tW1nB4mE7sD0xL3kJ6hG9fR2qV5yU8cP1aB4eX7zM0nQ3kL6jH9pR2tV5wY8xC1dF4gN7eM0lJ3kS6aZbY9cV2uI5oP8rQ1tW4nB7mE0sD3xL6kJ9hG2fR5qV8yU";
#[test]
fn truncate_shortens_to_keep_chars() {
let m = Mutation::Truncate { keep_chars: 10 };
let out = m.apply(REAL_TURNSTILE_SAMPLE);
assert_eq!(out.chars().count(), 10);
}
#[test]
fn pad_with_filler_appends_chars() {
let m = Mutation::PadWithFiller {
len: 50,
fill_char: b'a',
};
let out = m.apply("0.short");
assert_eq!(out.len(), 7 + 50);
assert!(out.ends_with("aaaa"));
}
#[test]
fn strip_prefix_removes_known_prefix() {
assert_eq!(Mutation::StripPrefix.apply("0.abc"), "abc");
assert_eq!(Mutation::StripPrefix.apply("P0_xyz"), "xyz");
assert_eq!(Mutation::StripPrefix.apply("03AGdBq25_token"), "token");
}
#[test]
fn strip_dots_removes_all_dots() {
let out = Mutation::StripDots.apply("a.b.c.d");
assert_eq!(out, "abcd");
}
#[test]
fn inject_html_inserts_script_tag() {
let out = Mutation::InjectHtml.apply("0.abcdefghij");
assert!(out.contains("<script>"));
}
#[test]
fn inject_whitespace_inserts_newline() {
let out = Mutation::InjectWhitespace.apply("0.abcdefghij");
assert!(out.contains('\n'));
}
#[test]
fn swap_base64_to_base64url_swaps_chars() {
let out = Mutation::SwapBase64ToBase64url.apply("a-b_c-d");
assert_eq!(out, "a+b/c+d");
}
#[test]
fn hex_only_filters_to_hex() {
let out = Mutation::HexOnlyConvert.apply("0.aBc1DeF2.zzz");
for c in out.chars() {
assert!(c.is_ascii_hexdigit() || c == '.');
}
}
#[test]
fn zero_body_replaces_body_with_zeros() {
let out = Mutation::ZeroBody.apply(REAL_TURNSTILE_SAMPLE);
assert!(out.starts_with("0."));
assert!(out[2..].chars().all(|c| c == '0'));
}
#[test]
fn wrong_prefix_replaces_prefix() {
let m = Mutation::WrongPrefix { prefix: "9." };
let out = m.apply("0.abc");
assert_eq!(out, "9.abc");
}
#[test]
fn inject_zero_width_inserts_zwsp() {
let out = Mutation::InjectZeroWidth.apply("0.abcdef");
assert!(out.contains('\u{200B}'));
}
#[test]
fn prefix_only_keeps_just_prefix() {
assert_eq!(Mutation::PrefixOnly.apply("0.aaaaaaaaaaaaaaaa"), "0.");
assert_eq!(Mutation::PrefixOnly.apply("P0_xxxxxxxxxxxxx"), "P0_");
}
#[test]
fn every_standard_mutation_produces_decoy_for_real_sample() {
use crate::solver::decoy_detector::{classify, DecoyVerdict};
let mutations = standard_mutations();
let mut failures = Vec::new();
for m in &mutations {
let mutated = m.apply(REAL_TURNSTILE_SAMPLE);
let verdict = classify(&mutated, "turnstile");
if verdict != DecoyVerdict::Decoy {
failures.push((m.name(), verdict, mutated));
}
}
assert!(
failures.len() <= 4,
"{} mutations not caught by statistical detector alone: {:?}",
failures.len(),
failures.iter().map(|(n, v, _)| (n, v)).collect::<Vec<_>>()
);
}
#[test]
fn standard_mutations_lists_at_least_15() {
assert!(standard_mutations().len() >= 15);
}
#[test]
fn standard_mutations_each_have_distinct_name() {
let mut seen = std::collections::HashSet::new();
for m in standard_mutations() {
let name = m.name();
let key = format!("{:?}", m);
seen.insert(key);
assert!(!name.is_empty());
}
assert!(seen.len() >= 15);
}
#[test]
fn generate_produces_one_pair_per_mutation() {
let m = standard_mutations();
let pairs = generate(REAL_TURNSTILE_SAMPLE, &m);
assert_eq!(pairs.len(), m.len());
}
#[test]
fn scale_10k_seed_mutation_combinations_all_produce_strings() {
use rand::{rngs::StdRng, Rng, SeedableRng};
let mut rng = StdRng::seed_from_u64(0xC0FFEE);
let m = standard_mutations();
let mut sum_len = 0usize;
for _ in 0..10_000 {
let len: usize = rng.gen_range(200..400);
let body: String = (0..len)
.map(|_| {
let idx: u8 = rng.gen_range(0..62);
match idx {
0..=25 => (b'A' + idx) as char,
26..=51 => (b'a' + (idx - 26)) as char,
_ => (b'0' + (idx - 52)) as char,
}
})
.collect();
let token = format!("0.{}", body);
for mu in &m {
let mutated = mu.apply(&token);
sum_len += mutated.len();
}
}
assert!(sum_len > 0);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 10_000, .. proptest::test_runner::Config::default()
})]
#[test]
fn prop_apply_never_panics(
body in proptest::collection::vec(b'!'..=b'~', 0..400),
) {
let token = String::from_utf8(body).unwrap();
for m in standard_mutations() {
let _ = m.apply(&token);
}
}
#[test]
fn prop_strip_prefix_never_grows(
body in proptest::collection::vec(b'A'..=b'z', 0..400),
) {
let token = String::from_utf8(body).unwrap();
let out = Mutation::StripPrefix.apply(&token);
assert!(out.len() <= token.len());
}
#[test]
fn prop_truncate_respects_keep(
body in proptest::collection::vec(b'A'..=b'z', 0..400),
keep in 0usize..400,
) {
let token = String::from_utf8(body).unwrap();
let out = Mutation::Truncate { keep_chars: keep }.apply(&token);
assert!(out.chars().count() <= keep);
}
#[test]
fn prop_prefix_only_is_idempotent(
body in proptest::collection::vec(b'A'..=b'z', 0..400),
) {
let token = String::from_utf8(body).unwrap();
let out = Mutation::PrefixOnly.apply(&token);
let twice = Mutation::PrefixOnly.apply(&out);
assert_eq!(out, twice);
}
#[test]
fn prop_wrong_prefix_preserves_body(
body in proptest::collection::vec(b'A'..=b'z', 5..200),
) {
let token = String::from_utf8(body).unwrap();
let m = Mutation::WrongPrefix { prefix: "9." };
let out = m.apply(&token);
let original_body = strip_prefix(&token);
assert!(out.ends_with(original_body));
}
}