captchaforge 0.2.38

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for Firefox + BiDi-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / BiDi fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! Massive-scale property tests — 100k cases per invariant.
//!
//! The per-module `proptest!` blocks already run 10k cases per
//! invariant; this file bumps the most-critical invariants to
//! 100k. Total executions across this file alone: ~12 × 100k =
//! ~1.2M random cases.
//!
//! Costly to run in tight loops; gated to release-profile or via
//! `--release` for CI. Default build still runs at 100k (proptest
//! itself is fast even for non-trivial predicates).
//!
//! What we assert at scale:
//! - The decoy detector never panics on any 0..512-byte input.
//! - The token oracle classifies the empty string and any string
//!   under min_len as Decoy across every vendor.
//! - Trajectory rendering never produces zero samples.
//! - The Beta-sample method always returns a value in [0, 1].
//! - The bandit's argmax is always a member of the candidate
//!   list, even with degenerate priors.

use captchaforge::solver::bandit::Bandit;
use captchaforge::solver::decoy_detector::{classify, extract_features, DecoyVerdict};
use captchaforge::solver::token_shapes::{for_vendor, TokenShape};
use captchaforge::solver::{CaptchaType, SolveMethod};
use proptest::prelude::*;

proptest! {
    #![proptest_config(ProptestConfig {
        // 100k random cases per invariant — single-process, ~1-3s
        // wall-clock per invariant on a modern CPU.
        cases: 100_000,
        max_local_rejects: 100_000_000,
        max_global_rejects: 100_000_000,
        .. ProptestConfig::default()
    })]

    /// The detector must never panic on any short input. Catches
    /// integer-overflow, divide-by-zero, slice-OOB regressions.
    #[test]
    fn scale_decoy_never_panics_on_short_input(
        bytes in prop::collection::vec(0u8..=255, 0..400),
    ) {
        let token = String::from_utf8_lossy(&bytes).to_string();
        for vendor in [
            "turnstile", "hcaptcha", "recaptcha_v2", "recaptcha_v3",
            "arkose", "datadome", "aws_waf", "akamai", "perimeterx",
            "geetest",
        ] {
            let _ = classify(&token, vendor);
        }
    }

    /// Every feature is in [0, 1] for any input.
    #[test]
    fn scale_decoy_features_in_unit_interval(
        bytes in prop::collection::vec(b'!'..=b'~', 0..512),
    ) {
        let token = String::from_utf8(bytes).unwrap();
        let f = extract_features(&token, "turnstile");
        for v 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,
        ] {
            prop_assert!(v >= 0.0 && v <= 1.0, "feature out of [0,1]: {v}");
        }
    }

    /// Decoy classification is monotone under length truncation:
    /// truncating a token always preserves "non-Plausible" status.
    #[test]
    fn scale_decoy_truncation_monotone(
        bytes in prop::collection::vec(b'A'..=b'Z', 50..400),
        truncate_to in 0usize..50,
    ) {
        let full = String::from_utf8(bytes.clone()).unwrap();
        let truncated: String = full.chars().take(truncate_to).collect();
        // Below min_len → always Decoy.
        if truncate_to < 200 {
            prop_assert_eq!(classify(&truncated, "turnstile"), DecoyVerdict::Decoy);
        }
    }

    /// Empty string is always Decoy.
    #[test]
    fn scale_empty_string_always_decoy(
        vendor in r"turnstile|hcaptcha|recaptcha_v2|recaptcha_v3|geetest|arkose|datadome|aws_waf|akamai|perimeterx",
    ) {
        prop_assert_eq!(classify("", &vendor), DecoyVerdict::Decoy);
    }

    /// for_vendor's oracle classifies any string under min_len as
    /// Decoy.
    #[test]
    fn scale_oracle_under_min_len_always_decoy(
        bytes in prop::collection::vec(b'A'..=b'Z', 0..60),
    ) {
        let s = String::from_utf8(bytes).unwrap();
        // hCaptcha oracle min_len is 80; anything under always Decoy.
        let oracle = for_vendor("hcaptcha").unwrap();
        prop_assert_eq!(oracle.classify(&s), TokenShape::Decoy);
    }

    /// Bandit's choose() must always return one of the candidates.
    #[test]
    fn scale_bandit_choose_returns_member(
        seed_obs_count in 0u32..100,
        n_candidates in 1usize..6,
    ) {
        let candidates: Vec<SolveMethod> = vec![
            SolveMethod::AutoPass,
            SolveMethod::BehavioralBypass,
            SolveMethod::VisionLLM,
            SolveMethod::AudioBypass,
            SolveMethod::CrowdSourced,
        ].into_iter().take(n_candidates).collect();
        let bandit = Bandit::new();
        let ct = CaptchaType::CloudflareTurnstile;
        for _ in 0..seed_obs_count {
            let chosen = bandit.choose(&ct, &candidates).unwrap();
            prop_assert!(candidates.contains(&chosen));
            bandit.observe(&ct, &chosen, false);
        }
    }

    /// Bandit's observe is idempotent in shape: after observe(),
    /// snapshot() returns at least one arm.
    #[test]
    fn scale_bandit_observe_grows_arm_set(
        n_observes in 1u32..100,
    ) {
        let bandit = Bandit::new();
        for _ in 0..n_observes {
            bandit.observe(
                &CaptchaType::CloudflareTurnstile,
                &SolveMethod::AutoPass,
                true,
            );
        }
        prop_assert!(bandit.len() >= 1);
        let snap = bandit.snapshot();
        prop_assert_eq!(snap.len(), 1);
        prop_assert!(snap[0].alpha >= 1.0 + n_observes as f64);
    }

    /// Decoy CVE corpus shapes: high-entropy random body with the
    /// '0.' prefix but mismatched length should always Decoy
    /// (length-only gate before features).
    #[test]
    fn scale_short_prefixed_token_always_decoy(
        body in r"[A-Za-z0-9_\-]{1,100}",
    ) {
        let token = format!("0.{}", body);
        prop_assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
    }

    /// Pad-only decoys (one repeated character) of any length must
    /// be classified as Decoy. The length-prefix-only check passes;
    /// the entropy/run-length features must catch it.
    #[test]
    fn scale_padded_filler_decoys_caught(
        len in 220usize..400,
        fill in b'a'..=b'z',
    ) {
        let body: String = std::iter::repeat(fill as char).take(len).collect();
        let token = format!("0.{}", body);
        prop_assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
    }

    /// HTML-tag injected tokens must always be Decoy.
    #[test]
    fn scale_html_injection_always_decoy(
        prefix in r"[01]\.",
        body_a in r"[A-Za-z0-9_\-]{100,200}",
        body_b in r"[A-Za-z0-9_\-]{50,100}",
    ) {
        let token = format!("{}{}<script>{}</script>", prefix, body_a, body_b);
        prop_assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
    }

    /// Whitespace-containing tokens always Decoy.
    #[test]
    fn scale_whitespace_always_decoy(
        prefix in r"[01]\.",
        body_a in r"[A-Za-z0-9_\-]{100,200}",
        body_b in r"[A-Za-z0-9_\-]{50,100}",
    ) {
        let token = format!("{}{}\n{}", prefix, body_a, body_b);
        prop_assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
    }
}