captchaforge 0.2.40

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! Unit tests for [`super`] (adversarial decoy-tile detection).

use super::*;

const REAL_TURNSTILE_SAMPLE: &str = "0.aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ8tW1nB4mE7sD0xL3kJ6hG9fR2qV5yU8cP1aB4eX7zM0nQ3kL6jH9pR2tV5wY8xC1dF4gN7eM0lJ3kS6aZbY9cV2uI5oP8rQ1tW4nB7mE0sD3xL6kJ9hG2fR5qV8yU";

// ─── Per-mutation correctness ──────────────────────────────────

#[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");
    // All chars in result must be ascii_hexdigit or '.'.
    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_");
}

// ─── End-to-end: every mutation produces a Decoy ───────────────

#[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));
        }
    }
    // Allow up to 2 borderline mutations to NOT be flagged as
    // Decoy by the statistical detector alone (some mutations
    // like swap_base64_to_base64url survive the entropy/charset
    // check because + and / are still printable). The CVE replay
    // corpus combines this with the token_shapes oracle which
    // does catch them.
    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();
        // Same NAME can repeat (with different params), e.g.
        // multiple Truncate / CharSwap entries. Track (name,
        // serialized-params) instead.
        let key = format!("{:?}", m);
        seen.insert(key);
        // sanity: name must be non-empty.
        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());
}

// ─── Scale: 10k seed-mutation combinations ─────────────────────

#[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 {
        // Generate a random real-shape sample.
        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);
        // Apply each mutation; assert no panic + non-empty for
        // most mutations.
        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);
        // Body after `9.` should equal the original body (after
        // stripping any matched prefix from input).
        let original_body = strip_prefix(&token);
        assert!(out.ends_with(original_body));
    }
}