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
//! Live end-to-end: reynard engine + `TurnstileInteractiveSolver` vs a real,
//! FORCE-INTERACTIVE Cloudflare Turnstile (CF test sitekey `3x…FF`) on a local
//! page. The widget renders its checkbox in a real cross-origin OOPIF
//! (`challenges.cloudflare.com`), so this exercises the exact production case the
//! solver exists for — and the cross-origin checkbox click that foxdriver warns
//! a top-context click cannot reliably deliver.
//!
//! No outward-facing auth: CF's test sitekeys work on localhost and issue only a
//! dummy token, and only after a real interaction. Opt-in:
//! ```text
//! REYNARD_BIN=/…/dist/bin/camoufox DISPLAY=:1 MOZ_DISABLE_CONTENT_SANDBOX=1 \
//!   cargo test -p captchaforge --test turnstile_interactive_live -- --nocapture
//! ```

use std::time::Duration;

use captchaforge::detect::{CaptchaInfo, DetectedCaptcha};
use captchaforge::solver::{CaptchaSolver, SolveConfig, TurnstileInteractiveSolver};
use guise::browser::launch_reynard;
use guise::fingerprint::StealthProfile;

mod common;

// `3x00000000000000000000FF` = CF's "force an interactive challenge" test
// sitekey: always renders the checkbox and requires a real interaction.
const PAGE: &str = r#"<!doctype html><html><head><meta charset="utf-8"><title>ts-interactive</title>
<script>
  window.onloadTurnstileCallback = function () {
    try {
      window.turnstile.render('#ts', { sitekey: '3x00000000000000000000FF' });
    } catch (e) { var d=document.getElementById('err'); if(d) d.textContent='render-err:'+e; }
  };
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback" async defer></script>
</head><body>
<h1>Turnstile interactive test</h1>
<form id="f"><div id="ts" class="cf-turnstile"></div></form>
<div id="err"></div>
</body></html>"#;

/// Length of the `cf-turnstile-response` token the widget injects on success
/// (unquoted attribute selector avoids nested-quote escaping).
const TOKEN_LEN_JS: &str =
    r#"(function(){var e=document.querySelector('input[name=cf-turnstile-response]');return e?(e.value||'').length:0;})()"#;

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reynard_solves_force_interactive_turnstile() {
    let Ok(reynard_bin) = std::env::var("REYNARD_BIN") else {
        eprintln!("SKIP turnstile_interactive_live: set REYNARD_BIN to run");
        return;
    };
    if std::env::var("DISPLAY").is_err() {
        eprintln!("SKIP turnstile_interactive_live: no DISPLAY");
        return;
    }

    let addr = common::serve_once(PAGE.to_string()).await;
    let url = format!("http://{addr}/");
    let page = launch_reynard(&reynard_bin, &StealthProfile::FirefoxLinux, false)
        .await
        .expect("launch reynard");
    page.goto(&url).await.expect("navigate to local turnstile page");
    // Let the Turnstile widget render its cross-origin iframe.
    tokio::time::sleep(Duration::from_millis(3500)).await;

    // Diagnostic: did the widget render at all? (Turnstile may refuse a
    // non-secure context / unknown domain — distinguishes "didn't render" from
    // "rendered but click missed".)
    let dom = page
        .evaluate(
            r#"(function(){
                var ifr=document.querySelectorAll('iframe');
                var srcs=[];for(var i=0;i<ifr.length;i++){srcs.push((ifr[i].src||'').slice(0,60));}
                return JSON.stringify({
                    turnstileGlobal: typeof window.turnstile,
                    iframeCount: ifr.length,
                    iframeSrcs: srcs,
                    cfDiv: !!document.querySelector('.cf-turnstile'),
                    isSecure: window.isSecureContext,
                    bodyLen: document.body?document.body.innerText.length:0
                });
            })()"#,
        )
        .await
        .ok()
        .and_then(|e| e.into_value::<String>().ok())
        .unwrap_or_default();
    eprintln!("[ts-interactive] DOM after load: {dom}");

    // Environmental skip: some hosts won't render the Turnstile widget iframe on
    // a local serve_once origin even though api.js loads (isSecureContext is true
    // for localhost). That is a harness limitation, not a solver bug — the click
    // path itself is proven by foxdriver's `cross_origin_click` test. Skip rather
    // than false-fail; this test does its job against a render-capable surface.
    let iframe_count = page
        .evaluate("document.querySelectorAll('iframe').length")
        .await
        .ok()
        .and_then(|e| e.into_value::<u64>().ok())
        .unwrap_or(0);
    if iframe_count == 0 {
        eprintln!(
            "SKIP turnstile_interactive_live: Turnstile widget did not render an iframe \
             on the local origin (harness limitation, not a solver bug) — DOM: {dom}"
        );
        let _ = page.close().await;
        return;
    }

    let info = CaptchaInfo {
        kind: DetectedCaptcha::Turnstile,
        site_key: Some("3x00000000000000000000FF".into()),
        page_url: url.clone(),
        container_selector: Some(".cf-turnstile".into()),
    };
    let solver = TurnstileInteractiveSolver::new().with_config(SolveConfig {
        checkbox_max_attempts: 12,
        token_max_attempts: 20,
        ..SolveConfig::default()
    });
    let res = solver.solve(&page, &info).await;

    let token_len = page
        .evaluate(TOKEN_LEN_JS)
        .await
        .ok()
        .and_then(|e| e.into_value::<u64>().ok())
        .unwrap_or(0);
    let _ = page.close().await;

    let res = res.expect("solver returned an error");
    eprintln!(
        "[ts-interactive] success={} solution={:?} token_len={}",
        res.success, res.solution, token_len
    );
    assert!(
        res.success || token_len > 0,
        "TurnstileInteractiveSolver did NOT solve the force-interactive widget \
         (success={}, token_len={}) — the cross-origin checkbox click likely did \
         not reach the OOPIF",
        res.success,
        token_len
    );
}