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
//! Persistent solver-decision LRU keyed by (vendor, fingerprint).
//!
//! Beyond `PatternStore` (per-domain): captchaforge increasingly
//! runs across many stealth profiles (JA3/JA4/Akamai-H2/UA). The
//! winning solver for a given vendor often depends on the
//! STACK fingerprint, not just the domain. This LRU lets the chain
//! short-circuit "for hCaptcha-on-Chrome131WinPQ, BehavioralBypass
//! won 12 of 14 attempts" → start there next time.
//!
//! Design contract:
//! - Bounded memory: `cap` entries; LRU eviction.
//! - Persisted to a single JSON file on `flush()`. Loaded eagerly
//!   on `open()`.
//! - Atomic write via tmp-file + rename so a crash mid-flush
//!   cannot corrupt the persisted state.
//! - Each entry carries a confidence score derived from sample
//!   count + smoothed win rate. Below `min_confidence`, the chain
//!   ignores the LRU and falls back to its normal ordering.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};

/// One LRU entry, a (vendor, fingerprint-hash) → preferred solver
/// outcome.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FingerprintEntry {
    pub vendor: String,
    /// Stable hash of the stealth profile / TLS fingerprint
    /// (e.g. SHA-256 of `<JA4>|<UA>|<lang>`). Keep opaque, only
    /// equality matters.
    pub fingerprint: String,
    /// Solver name that historically wins for this pair.
    pub best_solver: String,
    /// Win count.
    pub wins: u32,
    /// Total attempt count.
    pub attempts: u32,
    /// Last-access unix-epoch seconds. Drives LRU eviction.
    pub last_seen_unix: i64,
}

impl FingerprintEntry {
    /// Smoothed win rate. Returns 0.0 for zero-attempt entries.
    pub fn win_rate(&self) -> f32 {
        if self.attempts == 0 {
            0.0
        } else {
            self.wins as f32 / self.attempts as f32
        }
    }

    /// Confidence: 0..1, monotone in attempts (more samples → more
    /// confident) and in win_rate. 5 attempts is the floor for any
    /// meaningful confidence.
    pub fn confidence(&self) -> f32 {
        if self.attempts < 5 {
            return 0.0;
        }
        // Beta-distribution-style estimator: posterior mean shrinks
        // win_rate toward 0.5 with a (2, 2) prior. Stable for low N.
        let alpha = (self.wins as f32) + 2.0;
        let beta = (self.attempts as f32 - self.wins as f32) + 2.0;
        alpha / (alpha + beta)
    }
}

/// LRU-bounded persistent store of (vendor, fingerprint) → best_solver.
pub struct FingerprintLru {
    inner: RwLock<HashMap<String, FingerprintEntry>>,
    path: Option<PathBuf>,
    cap: usize,
    min_confidence: f32,
}

impl FingerprintLru {
    fn key(vendor: &str, fingerprint: &str) -> String {
        format!("{vendor}|{fingerprint}")
    }

    /// New in-memory LRU with the given capacity.
    pub fn new(cap: usize) -> Self {
        Self {
            inner: RwLock::new(HashMap::new()),
            path: None,
            cap: cap.max(1),
            min_confidence: 0.6,
        }
    }

    /// Open the LRU at the given path. Loads existing state on
    /// success; missing file is NOT an error (returns an empty
    /// LRU bound to that path so the next `flush()` creates it).
    pub fn open(path: impl AsRef<Path>, cap: usize) -> anyhow::Result<Self> {
        let path = path.as_ref().to_path_buf();
        let inner = if path.is_file() {
            let body = std::fs::read_to_string(&path)?;
            let entries: Vec<FingerprintEntry> = serde_json::from_str(&body).unwrap_or_default();
            let mut map = HashMap::new();
            for e in entries {
                let k = Self::key(&e.vendor, &e.fingerprint);
                map.insert(k, e);
            }
            map
        } else {
            HashMap::new()
        };
        Ok(Self {
            inner: RwLock::new(inner),
            path: Some(path),
            cap: cap.max(1),
            min_confidence: 0.6,
        })
    }

    /// Override the minimum confidence threshold. Below this, the
    /// chain treats the LRU entry as "no signal".
    pub fn with_min_confidence(mut self, threshold: f32) -> Self {
        self.min_confidence = threshold.clamp(0.0, 1.0);
        self
    }

    /// Record an attempt outcome.
    pub fn record(&self, vendor: &str, fingerprint: &str, solver: &str, success: bool) {
        let k = Self::key(vendor, fingerprint);
        let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
        let now = now_unix();
        let entry = map.entry(k).or_insert_with(|| FingerprintEntry {
            vendor: vendor.into(),
            fingerprint: fingerprint.into(),
            best_solver: solver.into(),
            wins: 0,
            attempts: 0,
            last_seen_unix: now,
        });
        entry.attempts = entry.attempts.saturating_add(1);
        if success {
            entry.wins = entry.wins.saturating_add(1);
            // Sticky: only replace `best_solver` when this solver
            // has 60%+ of attempts (otherwise a single one-off win
            // shouldn't kick out a higher-evidence incumbent).
            if entry.best_solver != solver
                && success
                && entry.attempts >= 3
                && entry.wins as f32 / entry.attempts as f32 >= 0.6
            {
                entry.best_solver = solver.into();
            }
        }
        entry.last_seen_unix = now;
        self.maybe_evict(&mut map);
    }

    fn maybe_evict(&self, map: &mut HashMap<String, FingerprintEntry>) {
        if map.len() <= self.cap {
            return;
        }
        // Evict the oldest `last_seen_unix`. O(n) scan; n bounded by cap.
        let to_drop: Vec<String> = {
            let mut entries: Vec<(&String, &FingerprintEntry)> = map.iter().collect();
            entries.sort_by_key(|(_, e)| e.last_seen_unix);
            entries
                .iter()
                .take(map.len() - self.cap)
                .map(|(k, _)| (*k).clone())
                .collect()
        };
        for k in to_drop {
            map.remove(&k);
        }
    }

    /// Look up the best solver for a (vendor, fingerprint) pair if
    /// the LRU has high-confidence evidence. Returns `None` when the
    /// entry is missing or below `min_confidence`.
    pub fn best_solver(&self, vendor: &str, fingerprint: &str) -> Option<String> {
        let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
        let entry = map.get(&Self::key(vendor, fingerprint))?;
        if entry.confidence() >= self.min_confidence {
            Some(entry.best_solver.clone())
        } else {
            None
        }
    }

    /// Snapshot the current entries (for diagnostics, /metrics, …).
    pub fn entries(&self) -> Vec<FingerprintEntry> {
        self.inner
            .read()
            .unwrap_or_else(|e| e.into_inner())
            .values()
            .cloned()
            .collect()
    }

    /// Atomically persist to disk. Best-effort: a flush failure logs
    /// at debug and does NOT propagate (better to lose a sample
    /// than to abort a solve over a transient disk error).
    pub fn flush(&self) -> anyhow::Result<()> {
        let Some(path) = self.path.as_ref() else {
            return Ok(());
        };
        let entries = self.entries();
        let json = serde_json::to_vec_pretty(&entries)?;
        let tmp = path.with_extension("tmp");
        std::fs::write(&tmp, json)?;
        std::fs::rename(&tmp, path)?;
        Ok(())
    }
}

impl Default for FingerprintLru {
    fn default() -> Self {
        Self::new(1024)
    }
}

fn now_unix() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

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

    #[test]
    fn record_then_lookup_yields_best_solver_once_confident() {
        let lru = FingerprintLru::new(16).with_min_confidence(0.6);
        for _ in 0..10 {
            lru.record("hcaptcha", "fp_chrome131_win", "BehavioralBypass", true);
        }
        let pick = lru.best_solver("hcaptcha", "fp_chrome131_win");
        assert_eq!(pick.as_deref(), Some("BehavioralBypass"));
    }

    #[test]
    fn low_evidence_returns_none() {
        let lru = FingerprintLru::new(16);
        for _ in 0..2 {
            lru.record("turnstile", "fp_x", "VlmCaptchaSolver", true);
        }
        // Only 2 attempts (below the 5-attempt floor).
        assert!(lru.best_solver("turnstile", "fp_x").is_none());
    }

    #[test]
    fn lru_evicts_oldest_when_over_cap() {
        let lru = FingerprintLru::new(2);
        lru.record("v1", "fp_a", "S", true);
        std::thread::sleep(std::time::Duration::from_millis(1_100));
        lru.record("v2", "fp_b", "S", true);
        std::thread::sleep(std::time::Duration::from_millis(1_100));
        lru.record("v3", "fp_c", "S", true);
        let entries = lru.entries();
        assert_eq!(entries.len(), 2);
        let vendors: std::collections::HashSet<_> =
            entries.iter().map(|e| e.vendor.as_str()).collect();
        assert!(
            !vendors.contains("v1"),
            "v1 should have been evicted as oldest"
        );
    }

    #[test]
    fn round_trip_through_disk() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("fp_lru.json");
        {
            let lru = FingerprintLru::open(&path, 16).unwrap();
            for _ in 0..8 {
                lru.record("recaptcha-v2", "fp_safari", "TurnstileInteractive", true);
            }
            lru.flush().unwrap();
        }
        let lru2 = FingerprintLru::open(&path, 16).unwrap();
        assert_eq!(
            lru2.best_solver("recaptcha-v2", "fp_safari").as_deref(),
            Some("TurnstileInteractive")
        );
    }

    #[test]
    fn win_rate_and_confidence_monotone() {
        let mut e = FingerprintEntry {
            vendor: "v".into(),
            fingerprint: "f".into(),
            best_solver: "s".into(),
            wins: 0,
            attempts: 0,
            last_seen_unix: 0,
        };
        // Zero attempts: confidence 0.
        assert_eq!(e.confidence(), 0.0);
        e.attempts = 4;
        e.wins = 4;
        assert_eq!(e.confidence(), 0.0, "below 5-attempt floor");
        e.attempts = 100;
        e.wins = 100;
        let c_high = e.confidence();
        e.wins = 50;
        let c_mid = e.confidence();
        assert!(c_high > c_mid);
    }
}