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
//! Adversarial WAF response replay framework.
//!
//! Captures real WAF responses (HTML + headers + cookies) into a
//! corpus and replays them through the captchaforge detection +
//! oracle path. Goal: surface regressions when the chain stops
//! recognising or handling a WAF response shape we previously
//! solved.
//!
//! Use cases:
//! - A user reports captchaforge stopped working on site X. Capture
//!   the response (`captchaforge corpus capture <url>`: TODO);
//!   add to corpus. Tests now reproduce it deterministically.
//! - Vendor rolls a new challenge shape. Capture once; the regression
//!   test prevents future drift.
//! - CI replays the entire adversarial corpus on every PR, catches
//!   detection regressions before merge.
//!
//! Storage layout:
//! ```text
//! adversarial-corpus/
//!   <vendor>/
//!     <capture-id>.json   # AdversarialCapture serialised
//! ```
//!
//! `AdversarialCapture` is pure data, no live HTTP, so the replay
//! suite is fast (sub-ms per capture) and deterministic.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// One captured WAF response, replayable without a live network.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdversarialCapture {
    /// Stable id (typically content-hash of the body) so reruns
    /// produce the same key.
    pub id: String,
    /// Vendor name (matches a `community.toml` rule `name`).
    pub vendor: String,
    /// HTTP status code from the original capture.
    pub status: u16,
    /// HTTP headers from the original capture, lowercased name.
    #[serde(default)]
    pub headers: HashMap<String, String>,
    /// Response body bytes (already decoded UTF-8; the production
    /// path's body cap applies).
    pub body: String,
    /// Cookies set on the response (Set-Cookie names only).
    #[serde(default)]
    pub cookie_names: Vec<String>,
    /// What captchaforge SHOULD do when it sees this capture, the
    /// regression contract.
    pub expected: ExpectedOutcome,
    /// Free-text capture provenance ("manual", "user-report-#42",
    /// "ci-bench-2026-05-13").
    #[serde(default)]
    pub notes: String,
    /// Capture timestamp (unix seconds).
    pub captured_at_unix: i64,
}

/// The regression contract for an [`AdversarialCapture`]. Replay
/// either passes or fails against this expectation; CI runs the
/// entire corpus and asserts every capture's actual outcome ==
/// `expected`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExpectedOutcome {
    /// captchaforge should recognise this body as the named vendor
    /// challenge. `detect()` would return DetectedCaptcha::Custom(vendor)
    /// or a built-in variant matching the vendor.
    Recognised,
    /// captchaforge should NOT recognise this body as captchaforge-
    /// solvable, typical for "Access Denied" hard-block pages
    /// where solving isn't possible.
    HardBlocked,
    /// captchaforge should classify this body as no-captcha (the
    /// challenge was already passed or the page is unprotected).
    NoCaptcha,
}

/// On-disk corpus of [`AdversarialCapture`] entries.
#[derive(Debug)]
pub struct AdversarialCorpus {
    root: PathBuf,
}

impl AdversarialCorpus {
    /// Open (or create) a corpus at the given root.
    pub fn open(root: impl AsRef<Path>) -> anyhow::Result<Self> {
        let root = root.as_ref().to_path_buf();
        std::fs::create_dir_all(&root)?;
        Ok(Self { root })
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Persist a capture under `<root>/<vendor>/<id>.json`.
    pub fn append(&self, c: &AdversarialCapture) -> anyhow::Result<()> {
        let dir = self.root.join(&c.vendor);
        std::fs::create_dir_all(&dir)?;
        let path = dir.join(format!("{}.json", c.id));
        let body = serde_json::to_vec_pretty(c)?;
        let tmp = path.with_extension("tmp");
        std::fs::write(&tmp, body)?;
        std::fs::rename(&tmp, &path)?;
        Ok(())
    }

    /// Load every capture for `vendor`. Skips files that fail to
    /// parse (logs at debug, doesn't propagate, a single corrupt
    /// file shouldn't break the whole replay run).
    pub fn load_vendor(&self, vendor: &str) -> anyhow::Result<Vec<AdversarialCapture>> {
        let dir = self.root.join(vendor);
        if !dir.is_dir() {
            return Ok(Vec::new());
        }
        let mut out = Vec::new();
        for entry in std::fs::read_dir(&dir)? {
            let entry = entry?;
            if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
                continue;
            }
            match std::fs::read_to_string(entry.path())
                .ok()
                .and_then(|s| serde_json::from_str::<AdversarialCapture>(&s).ok())
            {
                Some(c) => out.push(c),
                None => tracing::debug!(
                    path = %entry.path().display(),
                    "adversarial-corpus entry failed to parse, skipping"
                ),
            }
        }
        Ok(out)
    }

    /// Names of every vendor with at least one capture.
    pub fn vendors(&self) -> anyhow::Result<Vec<String>> {
        let mut out = Vec::new();
        for entry in std::fs::read_dir(&self.root)? {
            let entry = entry?;
            if entry.path().is_dir() {
                if let Some(n) = entry.path().file_name().and_then(|s| s.to_str()) {
                    out.push(n.to_string());
                }
            }
        }
        out.sort();
        Ok(out)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    fn sample(id: &str, vendor: &str, expected: ExpectedOutcome) -> AdversarialCapture {
        AdversarialCapture {
            id: id.into(),
            vendor: vendor.into(),
            status: 403,
            headers: HashMap::new(),
            body: "<html><body><div class=\"cf-turnstile\"></div></body></html>".into(),
            cookie_names: vec!["cf_clearance".into()],
            expected,
            notes: "test capture".into(),
            captured_at_unix: 1_700_000_000,
        }
    }

    #[test]
    fn append_then_load_round_trip() {
        let tmp = tempdir().unwrap();
        let corpus = AdversarialCorpus::open(tmp.path()).unwrap();
        let c1 = sample("a1", "cloudflare-turnstile", ExpectedOutcome::Recognised);
        let c2 = sample("a2", "cloudflare-turnstile", ExpectedOutcome::HardBlocked);
        corpus.append(&c1).unwrap();
        corpus.append(&c2).unwrap();
        let mut back = corpus.load_vendor("cloudflare-turnstile").unwrap();
        back.sort_by(|a, b| a.id.cmp(&b.id));
        assert_eq!(back.len(), 2);
        assert_eq!(back[0].id, "a1");
        assert_eq!(back[0].expected, ExpectedOutcome::Recognised);
        assert_eq!(back[1].id, "a2");
        assert_eq!(back[1].expected, ExpectedOutcome::HardBlocked);
    }

    #[test]
    fn vendors_enumerates_subdirs() {
        let tmp = tempdir().unwrap();
        let corpus = AdversarialCorpus::open(tmp.path()).unwrap();
        corpus
            .append(&sample("x", "cf", ExpectedOutcome::Recognised))
            .unwrap();
        corpus
            .append(&sample("y", "hcaptcha", ExpectedOutcome::Recognised))
            .unwrap();
        let mut vendors = corpus.vendors().unwrap();
        vendors.sort();
        assert_eq!(vendors, vec!["cf".to_string(), "hcaptcha".to_string()]);
    }

    #[test]
    fn load_vendor_with_no_dir_returns_empty() {
        let tmp = tempdir().unwrap();
        let corpus = AdversarialCorpus::open(tmp.path()).unwrap();
        let entries = corpus.load_vendor("never-captured").unwrap();
        assert!(entries.is_empty());
    }

    #[test]
    fn corrupt_capture_is_skipped_not_propagated() {
        let tmp = tempdir().unwrap();
        let corpus = AdversarialCorpus::open(tmp.path()).unwrap();
        std::fs::create_dir_all(tmp.path().join("cf")).unwrap();
        std::fs::write(
            tmp.path().join("cf").join("garbage.json"),
            b"{not json at all",
        )
        .unwrap();
        // Should NOT panic; should return zero entries.
        let entries = corpus.load_vendor("cf").unwrap();
        assert!(entries.is_empty());
    }

    #[test]
    fn expected_outcome_serialises_snake_case() {
        let r = serde_json::to_string(&ExpectedOutcome::Recognised).unwrap();
        let h = serde_json::to_string(&ExpectedOutcome::HardBlocked).unwrap();
        let n = serde_json::to_string(&ExpectedOutcome::NoCaptcha).unwrap();
        assert_eq!(r, "\"recognised\"");
        assert_eq!(h, "\"hard_blocked\"");
        assert_eq!(n, "\"no_captcha\"");
    }
}