captchaforge 0.2.36

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for chromiumoxide-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 / CDP fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! Contract test for the rule-fixture corpus.
//!
//! Every generated fixture under `tests/rule_fixtures/` must:
//! - exist as a real file (positive + negative),
//! - be ≥ MIN_BYTES so it carries vendor-realistic chrome rather
//!   than the bare-selector stubs the corpus shipped historically,
//! - contain its vendor name as a marker (vendor-specific HTML,
//!   not a generic template).
//! Hand-curated fixtures (`HAND_WRITTEN_FIXTURES`) get the same
//! size + marker contract: real captures naturally clear it.

use std::path::PathBuf;

use captchaforge::detect::rules::built_in_rules;

const MIN_BYTES: u64 = 200;

fn root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("rule_fixtures")
}

#[test]
fn every_positive_fixture_is_at_least_min_bytes() {
    let set = built_in_rules().expect("bundled rules parse");
    let mut undersized: Vec<(String, u64)> = Vec::new();
    for rule in &set.providers {
        let p = root().join(&rule.name).join("positive.html");
        let size = p
            .metadata()
            .unwrap_or_else(|e| panic!("stat {}: {e}", p.display()))
            .len();
        if size < MIN_BYTES {
            undersized.push((rule.name.clone(), size));
        }
    }
    assert!(
        undersized.is_empty(),
        "fixtures below {MIN_BYTES}-byte vendor-chrome bar: {undersized:?}",
    );
}

#[test]
fn every_negative_fixture_is_at_least_min_bytes() {
    let set = built_in_rules().expect("bundled rules parse");
    let mut undersized: Vec<(String, u64)> = Vec::new();
    for rule in &set.providers {
        let p = root().join(&rule.name).join("negative.html");
        let size = p
            .metadata()
            .unwrap_or_else(|e| panic!("stat {}: {e}", p.display()))
            .len();
        if size < MIN_BYTES {
            undersized.push((rule.name.clone(), size));
        }
    }
    assert!(
        undersized.is_empty(),
        "negative fixtures below {MIN_BYTES}-byte bar: {undersized:?}",
    );
}

#[test]
fn every_positive_fixture_carries_a_vendor_marker() {
    // Vendor name (verbatim, or with dashes for hyphenated CSS) must
    // appear somewhere in positive HTML. Catches the regression where
    // a generic template ships without a vendor-specific marker.
    let set = built_in_rules().expect("bundled rules parse");
    let mut bare: Vec<String> = Vec::new();
    for rule in &set.providers {
        let p = root().join(&rule.name).join("positive.html");
        let body = std::fs::read_to_string(&p).unwrap();
        let dashed = rule.name.replace('_', "-");
        let spaced = rule.name.replace('_', " ");
        // Some hand-curated vendors use short brand names that don't
        // include the rule's full snake-case name; for those, accept
        // any first-selector token from the rule's triggers.
        let trigger_token: Option<&str> = rule
            .triggers
            .selectors
            .iter()
            .filter_map(|s| {
                s.trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
                    .split(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
                    .next()
            })
            .find(|s| !s.is_empty());
        let mut found = body.contains(&rule.name)
            || body.contains(&dashed)
            || body.contains(&spaced);
        if !found {
            if let Some(tok) = trigger_token {
                if body.contains(tok) {
                    found = true;
                }
            }
        }
        if !found {
            bare.push(rule.name.clone());
        }
    }
    assert!(
        bare.is_empty(),
        "positive fixtures missing vendor marker: {bare:?}",
    );
}

#[test]
fn generator_output_matches_in_tree_corpus() {
    // The generator under `tools/gen_fixtures.py` must reproduce the
    // exact bytes of every generated fixture. Catches drift between
    // the generator and the corpus — a future contributor changing
    // selectors in `rules/community.toml` and forgetting to re-run
    // the generator gets a loud test failure here, not a silent
    // false-positive corpus.
    let manifest_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let script = manifest_root.join("tools").join("gen_fixtures.py");
    if !script.is_file() {
        // Generator not yet present in this branch — skip rather than
        // hard-fail; community_rules test still enforces correctness.
        return;
    }
    let output = std::process::Command::new("python3")
        .arg(&script)
        .arg("--check")
        .current_dir(&manifest_root)
        .output()
        .expect("python3 must be on PATH");
    assert!(
        output.status.success(),
        "gen_fixtures.py --check exited {:?}\nstdout:\n{}\nstderr:\n{}",
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

#[test]
fn no_negative_fixture_contains_its_own_vendor_marker_strongly() {
    // The negative side should not carry the vendor's selectors. A
    // weak marker (vendor name appearing in prose like "No X widget is
    // loaded.") is fine — the existing community_rules harness already
    // enforces that no rule fires on its own negative. This test only
    // catches the egregious case where positive content leaks into
    // negative (e.g. accidental copy-paste).
    let set = built_in_rules().expect("bundled rules parse");
    let mut leaks: Vec<String> = Vec::new();
    for rule in &set.providers {
        let p = root().join(&rule.name).join("negative.html");
        let body = std::fs::read_to_string(&p).unwrap();
        for needle in rule.triggers.script_src_contains.iter() {
            if body.contains(needle) {
                leaks.push(format!(
                    "{}: negative contains script_src needle `{needle}`",
                    rule.name
                ));
            }
        }
    }
    assert!(
        leaks.is_empty(),
        "negative fixtures leak vendor script_src needles: {leaks:?}",
    );
}