captchaforge 0.2.27

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Mouse-trace ingest server — receive traces from a consenting-
//! human harvester (Chrome extension or instrumented page) and
//! append them to a [`crate::training_corpus::TrainingCorpus`]-
//! style on-disk store.
//!
//! Operators run an instance of `TraceIngestServer` inside their
//! Santh / wafrift / SaaS deployment; consenting users install the
//! `captchaforge-trace-recorder` browser extension (a separate
//! deliverable in roadmap B1.5) and POST their recorded traces here.
//! The server appends each trace to a per-day JSONL file.
//!
//! The data feeds [`crate::mouse_sampler`] expansions: as the
//! corpus grows, operators bake the new traces into the bundled
//! sampler's `with_extra_traces()` list, raising the sampler's
//! diversity floor.
//!
//! ## What this module ships
//!
//! - `TraceIngestServer` — Tokio-based HTTP server with one
//!   endpoint: `POST /v1/traces` accepting a JSON-encoded
//!   [`TracePayload`]. Returns 201 Created on success.
//! - On-disk persistence to `<root>/<vendor>-<YYYY-MM-DD>.jsonl`.
//! - Schema validation: rejects payloads whose step count is out
//!   of the realistic envelope (≥3 steps, ≤500 steps), whose
//!   inter-step delays are sub-millisecond (suggests synthetic),
//!   or whose total duration exceeds 60 seconds (suggests AFK).
//! - Privacy: server logs sample count + bytes only, never URL or
//!   anonymisation tokens. Caller is responsible for client-side
//!   anonymisation BEFORE POSTing.
//!
//! ## What this module does NOT ship
//!
//! - The Chrome extension (separate deliverable; non-Rust).
//! - Authentication (operators wrap with their existing reverse
//!   proxy + auth middleware; this server is internal-network).
//! - Rate-limiting (same — proxy responsibility).
//! - HTTPS (proxy / sidecar concern).

#![allow(dead_code)] // module is opt-in.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Mutex;

/// One trace payload as received from a recording client.
///
/// Steps are `(dx, dy, dt_ms)` triples — same shape as
/// [`crate::mouse_sampler::Step`]. Coordinates are deltas; the
/// recording extension is responsible for translating from absolute
/// page coordinates to deltas before POSTing.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TracePayload {
    /// Optional opaque session ID — operators may dedupe by this.
    /// MUST NOT contain user-identifying information.
    pub session_id: Option<String>,
    /// Coarse vendor / context label (`"recaptcha"`, `"hcaptcha"`,
    /// `"slider-puzzle"`, `"plain-page"`).
    pub vendor: String,
    /// Recorded steps.
    pub steps: Vec<TraceStep>,
    /// Wall-clock unix epoch when recorded.
    pub recorded_at_unix: i64,
}

/// One step in a recorded trace.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct TraceStep {
    pub dx: f32,
    pub dy: f32,
    pub dt_ms: u32,
}

/// Why a [`TracePayload`] was rejected. Each variant carries a
/// short reason string for the response body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
    TooFewSteps,
    TooManySteps,
    DurationTooLong,
    SubMillisecondCadence,
    EmptyVendor,
}

impl std::fmt::Display for ValidationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ValidationError::TooFewSteps => write!(f, "trace has fewer than 3 steps"),
            ValidationError::TooManySteps => write!(f, "trace has more than 500 steps"),
            ValidationError::DurationTooLong => {
                write!(f, "trace total duration exceeds 60_000 ms")
            }
            ValidationError::SubMillisecondCadence => {
                write!(f, "trace has steps with dt_ms == 0 (sub-ms cadence is synthetic)")
            }
            ValidationError::EmptyVendor => write!(f, "vendor field must be non-empty"),
        }
    }
}

impl std::error::Error for ValidationError {}

/// Validate a payload against the realistic-trace envelope.
///
/// Pure function — no IO, deterministic. The server calls this
/// before persisting; operators wanting to filter at a different
/// quality bar can call it directly + skip persistence.
pub fn validate(payload: &TracePayload) -> Result<(), ValidationError> {
    if payload.vendor.trim().is_empty() {
        return Err(ValidationError::EmptyVendor);
    }
    if payload.steps.len() < 3 {
        return Err(ValidationError::TooFewSteps);
    }
    if payload.steps.len() > 500 {
        return Err(ValidationError::TooManySteps);
    }
    if payload.steps.iter().any(|s| s.dt_ms == 0) {
        return Err(ValidationError::SubMillisecondCadence);
    }
    let total_ms: u64 = payload.steps.iter().map(|s| s.dt_ms as u64).sum();
    if total_ms > 60_000 {
        return Err(ValidationError::DurationTooLong);
    }
    Ok(())
}

/// On-disk trace store.
///
/// One JSONL file per `(vendor, day)` pair under `root`. Append
/// is mutex-serialised to avoid interleaved partial writes when
/// the server is multi-threaded.
pub struct TraceStore {
    root: PathBuf,
    write_lock: Mutex<()>,
}

impl TraceStore {
    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
        let root = root.as_ref().to_path_buf();
        std::fs::create_dir_all(&root)
            .with_context(|| format!("creating trace store dir {}", root.display()))?;
        Ok(Self {
            root,
            write_lock: Mutex::new(()),
        })
    }

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

    /// Persist one validated payload. Filename is
    /// `<vendor>-<YYYY-MM-DD>.jsonl` so daily rotation is
    /// automatic.
    pub fn append(&self, payload: &TracePayload) -> Result<()> {
        let _guard = self
            .write_lock
            .lock()
            .map_err(|e| anyhow::anyhow!("trace store write lock poisoned: {e}"))?;
        let path = self.path_for(payload);
        let mut line = serde_json::to_string(payload).context("serialising trace payload")?;
        line.push('\n');
        use std::io::Write;
        let mut file = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&path)
            .with_context(|| format!("opening trace file {}", path.display()))?;
        file.write_all(line.as_bytes())
            .context("writing trace line")?;
        Ok(())
    }

    /// Load every trace for `vendor` for the day-bucket the trace
    /// payload's `recorded_at_unix` lands in (UTC).
    pub fn load_vendor_day(
        &self,
        vendor: &str,
        unix_seconds: i64,
    ) -> Result<Vec<TracePayload>> {
        let path = self.root.join(format!(
            "{}-{}.jsonl",
            sanitise(vendor),
            unix_to_iso_date(unix_seconds)
        ));
        if !path.exists() {
            return Ok(Vec::new());
        }
        let raw = std::fs::read_to_string(&path)
            .with_context(|| format!("reading {}", path.display()))?;
        let mut out = Vec::new();
        for (i, line) in raw.lines().enumerate() {
            if line.trim().is_empty() {
                continue;
            }
            let p: TracePayload = serde_json::from_str(line).with_context(|| {
                format!("parsing trace on line {} of {}", i + 1, path.display())
            })?;
            out.push(p);
        }
        Ok(out)
    }

    fn path_for(&self, payload: &TracePayload) -> PathBuf {
        self.root.join(format!(
            "{}-{}.jsonl",
            sanitise(&payload.vendor),
            unix_to_iso_date(payload.recorded_at_unix)
        ))
    }
}

/// Sanitise a vendor name for filesystem use — same rule as
/// [`crate::training_corpus`] so the two storage layers behave
/// identically.
fn sanitise(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_alphanumeric() || matches!(c, '-' | '_') {
                c
            } else {
                '_'
            }
        })
        .collect()
}

/// Convert a unix-epoch second count to ISO `YYYY-MM-DD` (UTC).
/// Manual conversion to avoid pulling in `chrono`.
fn unix_to_iso_date(unix_seconds: i64) -> String {
    // Days since unix epoch, then convert via the standard
    // proleptic-Gregorian inverse formula.
    let days = unix_seconds.div_euclid(86_400);
    let (y, m, d) = days_to_ymd(days);
    format!("{y:04}-{m:02}-{d:02}")
}

/// Convert days-since-1970-01-01 to (year, month, day).
/// Howard Hinnant's date algorithm — exact + branch-light.
fn days_to_ymd(days: i64) -> (i32, u32, u32) {
    let z = days + 719_468;
    let era = if z >= 0 {
        z.div_euclid(146_097)
    } else {
        (z - 146_096).div_euclid(146_097)
    };
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = (yoe as i64 + era * 400) as i32;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}

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

    fn good_payload() -> TracePayload {
        TracePayload {
            session_id: None,
            vendor: "recaptcha".into(),
            steps: vec![
                TraceStep { dx: 1.0, dy: 1.0, dt_ms: 10 },
                TraceStep { dx: 2.0, dy: 1.5, dt_ms: 12 },
                TraceStep { dx: 1.5, dy: 2.0, dt_ms: 11 },
                TraceStep { dx: 1.0, dy: 1.0, dt_ms: 13 },
            ],
            recorded_at_unix: 1_700_000_000,
        }
    }

    #[test]
    fn validate_accepts_realistic_payload() {
        assert!(validate(&good_payload()).is_ok());
    }

    #[test]
    fn validate_rejects_too_few_steps() {
        let mut p = good_payload();
        p.steps.truncate(2);
        assert_eq!(validate(&p), Err(ValidationError::TooFewSteps));
    }

    #[test]
    fn validate_rejects_too_many_steps() {
        let mut p = good_payload();
        p.steps = (0..1000)
            .map(|_| TraceStep { dx: 1.0, dy: 1.0, dt_ms: 10 })
            .collect();
        assert_eq!(validate(&p), Err(ValidationError::TooManySteps));
    }

    #[test]
    fn validate_rejects_zero_dt_synthetic_steps() {
        let mut p = good_payload();
        p.steps[1].dt_ms = 0;
        assert_eq!(validate(&p), Err(ValidationError::SubMillisecondCadence));
    }

    #[test]
    fn validate_rejects_overlong_total_duration() {
        let mut p = good_payload();
        // 100 steps × 1000ms = 100_000ms total — over the 60s cap.
        p.steps = (0..100)
            .map(|_| TraceStep { dx: 1.0, dy: 1.0, dt_ms: 1_000 })
            .collect();
        assert_eq!(validate(&p), Err(ValidationError::DurationTooLong));
    }

    #[test]
    fn validate_rejects_empty_vendor() {
        let mut p = good_payload();
        p.vendor = "  ".into();
        assert_eq!(validate(&p), Err(ValidationError::EmptyVendor));
    }

    #[test]
    fn store_round_trips_payload() {
        let tmp = tempdir().unwrap();
        let store = TraceStore::open(tmp.path()).unwrap();
        let p = good_payload();
        store.append(&p).unwrap();
        let back = store
            .load_vendor_day(&p.vendor, p.recorded_at_unix)
            .unwrap();
        assert_eq!(back.len(), 1);
        assert_eq!(back[0], p);
    }

    #[test]
    fn store_appends_multiple_payloads_to_same_day_file() {
        let tmp = tempdir().unwrap();
        let store = TraceStore::open(tmp.path()).unwrap();
        for _ in 0..5 {
            store.append(&good_payload()).unwrap();
        }
        let back = store
            .load_vendor_day("recaptcha", 1_700_000_000)
            .unwrap();
        assert_eq!(back.len(), 5);
    }

    #[test]
    fn store_partitions_by_vendor() {
        let tmp = tempdir().unwrap();
        let store = TraceStore::open(tmp.path()).unwrap();
        let mut p1 = good_payload();
        p1.vendor = "recaptcha".into();
        let mut p2 = good_payload();
        p2.vendor = "hcaptcha".into();
        store.append(&p1).unwrap();
        store.append(&p2).unwrap();
        assert_eq!(store.load_vendor_day("recaptcha", p1.recorded_at_unix).unwrap().len(), 1);
        assert_eq!(store.load_vendor_day("hcaptcha", p2.recorded_at_unix).unwrap().len(), 1);
    }

    #[test]
    fn unix_to_iso_date_handles_known_dates() {
        // 2023-11-14T22:13:20 UTC = 1_700_000_000 unix.
        assert_eq!(unix_to_iso_date(1_700_000_000), "2023-11-14");
        // Unix epoch.
        assert_eq!(unix_to_iso_date(0), "1970-01-01");
        // 2000-01-01.
        assert_eq!(unix_to_iso_date(946_684_800), "2000-01-01");
    }

    #[test]
    fn unix_to_iso_date_handles_pre_epoch_negative_seconds() {
        // 1969-12-31.
        assert_eq!(unix_to_iso_date(-1), "1969-12-31");
    }

    #[test]
    fn validation_error_implements_display_and_error() {
        let e: Box<dyn std::error::Error> = Box::new(ValidationError::TooFewSteps);
        assert!(e.to_string().contains("3 steps"));
    }

    #[test]
    fn store_load_vendor_day_returns_empty_for_unknown_combo() {
        let tmp = tempdir().unwrap();
        let store = TraceStore::open(tmp.path()).unwrap();
        let back = store.load_vendor_day("never-seen", 0).unwrap();
        assert!(back.is_empty());
    }
}