captchaforge 0.2.39

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
//! The chain's core `solve` orchestration: snapshot baseline → cache
//! short-circuit (oracle-verified) → ordered solver loop with per-solver
//! timeout, overclaim/token-shape/outcome gating, pattern + telemetry
//! recording, and token-cache persistence → terminal human-fallback with
//! optional screenshot + training-corpus capture.
//!
//! Split out of `chain.rs` (Law 5). `super` here is the `chain` module, so the
//! sibling `solver::oracle` / `solver::token_shapes` modules are reached via
//! their absolute `crate::solver::…` paths; everything else (the chain's
//! struct, fields, the `oracle_for_kind` / `detected_kind_canonical_name`
//! helpers, and the prelude imports) comes from the parent via `use super::*`.

use super::*;

impl CaptchaSolverChain {
    /// Run the chain. Returns the first successful `CaptchaSolveResult`,
    /// or an unsolved result (optionally with a screenshot) if all strategies are exhausted.
    pub async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> CaptchaSolveResult {
        let domain = extract_domain(&captcha_info.page_url);
        let captcha_type = detected_to_type(&captcha_info.kind);
        let t0 = Instant::now();

        // Outcome-verification baseline. Snapshot the page BEFORE any
        // solver runs so we can classify whether the page actually
        // advanced after the solver claims success. Two cheap BiDi
        // evals; skipped entirely when verify_outcome is off.
        //
        // Snapshot BEFORE the cache short-circuit so cache hits also
        // get verified by the oracle. Previously `cached_solution`
        // returned immediately without snapshotting, so cache hits
        // were trusted on the cache's word alone, a stale token
        // for a captcha that's since regenerated would silently
        // "succeed" without any post-state evidence.
        let baseline = if self.config.verify_outcome {
            Some(crate::solver::oracle::take_snapshot(page).await)
        } else {
            None
        };

        // Cache short-circuit, see [`CaptchaSolverChain::cached_solution`] for
        // the standalone path. Done here too so `solve()` is a complete
        // top-level entry point.
        if let Some(mut hit) = self.cached_solution(captcha_info) {
            hit.time_ms = t0.elapsed().as_millis() as u64;
            // Verify the cached result against fresh page state when
            // verify_outcome is on. A stale cache that no longer
            // matches the page (e.g. token expired, widget recycled)
            // gets downgraded here instead of being trusted blindly.
            if let Some(before) = &baseline {
                let after = crate::solver::oracle::take_snapshot(page).await;
                let outcome = crate::solver::oracle::classify(before, &after);
                hit.verified_outcome = Some(outcome);
                let is_verified = matches!(
                    outcome,
                    crate::solver::oracle::OutcomeClassification::Advanced
                ) || (self.config.allow_unknown_outcome
                    && matches!(
                        outcome,
                        crate::solver::oracle::OutcomeClassification::Unknown
                    ));
                if !is_verified {
                    warn!(
                        outcome = ?outcome,
                        "token cache hit but oracle disagrees, not trusting cache"
                    );
                    hit.success = false;
                    // Fall through to the real solver chain below
                    // instead of returning the failed cache row.
                } else {
                    return hit;
                }
            } else {
                return hit;
            }
        }

        // Re-order solvers so the historically-best method for this domain runs first.
        let ordered = self.ordered_solvers(&domain, &captcha_type, &captcha_info.kind);

        for solver in &ordered {
            info!(solver = solver.name(), "attempting captcha solve");
            let timeout = Duration::from_millis(self.config.per_solver_timeout_ms);
            let result = tokio::time::timeout(timeout, solver.solve(page, captcha_info)).await;

            match result {
                Ok(Ok(mut r)) if r.success => {
                    // C046/C047 / Screwdriver, refuse a success that carries no
                    // token BEFORE any further processing. A solver reporting
                    // `success: true` with an empty `solution` has nothing to show
                    // for the solve; downgrade it loudly rather than let a fabricated
                    // success flow downstream. Legitimate sentinel proofs
                    // ("turnstile:passive", "datadome:cookie", …) are all non-empty,
                    // so this only ever catches the overclaim, never a real solve.
                    if r.claims_success_without_token() {
                        warn!(
                            solver = solver.name(),
                            "solver reported success with an empty token, refusing as an overclaim (C047)"
                        );
                        r.success = false;
                        self.patterns.record(
                            &domain,
                            &captcha_type,
                            false,
                            r.time_ms,
                            r.method.clone(),
                        );
                        self.telemetry.record(&SolveEvent {
                            solver: solver.name(),
                            captcha_type: &captcha_type,
                            kind: &captcha_info.kind,
                            domain: &domain,
                            outcome: SolveOutcome::Failure,
                            time_ms: r.time_ms,
                            confidence: None,
                            method: &r.method,
                        });
                        continue;
                    }
                    // Token-shape oracle (E2 wiring): when the
                    // detected captcha kind has a documented token
                    // shape, sanity-check the solver's returned
                    // string against it. Decoy = clearly malformed;
                    // soft-failure decoy tokens (vendors return
                    // these to make scrapers report success then
                    // bounce them at validation time) get
                    // intercepted HERE instead of two requests
                    // later when the token is rejected. Suspect =
                    // unrecognised but plausible (keep, log).
                    if let Some(oracle) = oracle_for_kind(&captcha_info.kind) {
                        match oracle.classify(&r.solution) {
                            crate::solver::token_shapes::TokenShape::Decoy => {
                                warn!(
                                    solver = solver.name(),
                                    vendor = oracle.vendor(),
                                    solution_len = r.solution.len(),
                                    "solver claimed success but token shape is decoy. \
                                     downgrading (likely vendor soft-failure response)"
                                );
                                r.success = false;
                                self.patterns.record(
                                    &domain,
                                    &captcha_type,
                                    false,
                                    r.time_ms,
                                    r.method.clone(),
                                );
                                self.telemetry.record(&SolveEvent {
                                    solver: solver.name(),
                                    captcha_type: &captcha_type,
                                    kind: &captcha_info.kind,
                                    domain: &domain,
                                    outcome: SolveOutcome::Failure,
                                    time_ms: r.time_ms,
                                    confidence: None,
                                    method: &r.method,
                                });
                                continue;
                            }
                            crate::solver::token_shapes::TokenShape::Suspect => {
                                tracing::debug!(
                                    solver = solver.name(),
                                    vendor = oracle.vendor(),
                                    "token shape Suspect, keeping success but flagging for re-verification"
                                );
                            }
                            crate::solver::token_shapes::TokenShape::Plausible => {}
                        }
                    }
                    // Verify outcome, the solver claims success, but
                    // does the page state agree? A token in hand is
                    // not the same as a page past the challenge.
                    if let Some(before) = &baseline {
                        let after = crate::solver::oracle::take_snapshot(page).await;
                        let outcome = crate::solver::oracle::classify(before, &after);
                        r.verified_outcome = Some(outcome);
                        // Strict-by-default: only Advanced verifies
                        // success. `Unknown` is downgraded unless the
                        // operator explicitly opted in via
                        // `allow_unknown_outcome` (for BiDi-flaky test
                        // environments). Previously Unknown was
                        // implicitly trusted, which let solver
                        // attempts succeed against snapshots where
                        // we couldn't actually verify anything.
                        let is_verified = matches!(
                            outcome,
                            crate::solver::oracle::OutcomeClassification::Advanced
                        ) || (self.config.allow_unknown_outcome
                            && matches!(
                                outcome,
                                crate::solver::oracle::OutcomeClassification::Unknown
                            ));
                        if !is_verified {
                            warn!(
                                solver = solver.name(),
                                outcome = ?outcome,
                                "solver claimed success but oracle disagrees, downgrading to failure"
                            );
                            r.success = false;
                            // Fall through to the failure-path arm below
                            // by re-binding via a continue. We can't
                            // mutate the match arm, so manually drive
                            // the failure-path side effects here:
                            self.patterns.record(
                                &domain,
                                &captcha_type,
                                false,
                                r.time_ms,
                                r.method.clone(),
                            );
                            self.telemetry.record(&SolveEvent {
                                solver: solver.name(),
                                captcha_type: &captcha_type,
                                kind: &captcha_info.kind,
                                domain: &domain,
                                outcome: SolveOutcome::Failure,
                                time_ms: r.time_ms,
                                confidence: None,
                                method: &r.method,
                            });
                            continue;
                        }
                    }

                    info!(
                        solver = solver.name(),
                        confidence = r.confidence,
                        time_ms = r.time_ms,
                        verified = ?r.verified_outcome,
                        "captcha solved"
                    );
                    self.patterns
                        .record(&domain, &captcha_type, true, r.time_ms, r.method.clone());
                    self.telemetry.record(&SolveEvent {
                        solver: solver.name(),
                        captcha_type: &captcha_type,
                        kind: &captcha_info.kind,
                        domain: &domain,
                        outcome: SolveOutcome::Success,
                        time_ms: r.time_ms,
                        confidence: Some(r.confidence),
                        method: &r.method,
                    });
                    if let Some(cache) = &self.cache {
                        // Persist the cookies alongside the token so a
                        // future cache hit replays the same trusted
                        // session, without this the cache layer
                        // returned only the token and the next request
                        // immediately re-triggered the captcha.
                        cache.put_full(
                            &domain,
                            &captcha_type,
                            r.solution.clone(),
                            solver.name(),
                            cache.ttl(),
                            r.cookies.clone(),
                        );
                    }
                    return r;
                }
                Ok(Ok(r)) => {
                    warn!(solver = solver.name(), "solver returned failure result");
                    self.patterns.record(
                        &domain,
                        &captcha_type,
                        false,
                        r.time_ms,
                        r.method.clone(),
                    );
                    self.telemetry.record(&SolveEvent {
                        solver: solver.name(),
                        captcha_type: &captcha_type,
                        kind: &captcha_info.kind,
                        domain: &domain,
                        outcome: SolveOutcome::Failure,
                        time_ms: r.time_ms,
                        confidence: None,
                        method: &r.method,
                    });
                }
                Ok(Err(e)) => {
                    warn!(solver = solver.name(), error = %e, "solver error");
                    let method = solver.method();
                    self.telemetry.record(&SolveEvent {
                        solver: solver.name(),
                        captcha_type: &captcha_type,
                        kind: &captcha_info.kind,
                        domain: &domain,
                        outcome: SolveOutcome::Error,
                        time_ms: 0,
                        confidence: None,
                        method: &method,
                    });
                }
                Err(_) => {
                    warn!(solver = solver.name(), "solver timed out");
                    self.patterns.record(
                        &domain,
                        &captcha_type,
                        false,
                        self.config.per_solver_timeout_ms,
                        solver.method(),
                    );
                    let method = solver.method();
                    self.telemetry.record(&SolveEvent {
                        solver: solver.name(),
                        captcha_type: &captcha_type,
                        kind: &captcha_info.kind,
                        domain: &domain,
                        outcome: SolveOutcome::Timeout,
                        time_ms: self.config.per_solver_timeout_ms,
                        confidence: None,
                        method: &method,
                    });
                }
            }
        }

        // All solvers exhausted (optionally grab a screenshot for human review).
        warn!("all captcha solvers failed, human fallback required");
        let screenshot = if self.config.screenshot_on_failure {
            screenshot_b64(page).await.ok()
        } else {
            None
        };

        // Adversarial-training capture (H2): when a TrainingCorpus
        // is configured, persist this terminal failure as a sample
        // so downstream re-training pipelines see it. Best-effort 
        // disk failure logs at debug, never propagates.
        if let Some(corpus) = &self.training_corpus {
            let sample = crate::training_corpus::TrainingSample {
                solver: "(chain-terminal)".into(),
                vendor: detected_kind_canonical_name(&captcha_info.kind),
                detected_kind: format!("{:?}", captcha_info.kind),
                url: captcha_info.page_url.clone(),
                outcome: "failure".into(),
                confidence: None,
                time_ms: t0.elapsed().as_millis() as u64,
                screenshot_b64: screenshot.clone(),
                dom_snapshot: None,
                verified_outcome: None,
                captured_at_unix: std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map(|d| d.as_secs() as i64)
                    .unwrap_or(0),
            };
            if let Err(e) = corpus.append(&sample) {
                // Law 10: a swallowed append silently drops a real training
                // sample, the ML solver then trains on a biased slice. Surface
                // it loudly (the solve still continues; only telemetry is lost).
                tracing::warn!(error = %e, "captchaforge: training-corpus append failed (continuing); this solve's sample was NOT recorded");
            }
        }

        CaptchaSolveResult::unsolved(t0.elapsed().as_millis() as u64, screenshot)
    }
}