captchaforge 0.2.34

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Dynamic-load plugin system for custom CaptchaSolver impls.
//!
//! Enterprises ship private solvers — e.g. an internal Akamai
//! sensor-data generator, a VLM model behind a corporate proxy, a
//! browser-extension bridge. Bundling these into upstream
//! captchaforge isn't an option (proprietary code, NDA, scale of
//! review). The plugin system lets those shops:
//!
//! 1. Build a `cdylib` exposing the captchaforge plugin ABI.
//! 2. Drop the resulting `.so` / `.dylib` / `.dll` into a plugin
//!    directory.
//! 3. Pass the directory path to
//!    [`CaptchaSolverChain::with_plugin_dir`] at startup.
//!
//! ## ABI contract
//!
//! Each plugin exports ONE symbol — `captchaforge_plugin_entry` —
//! whose signature is exactly:
//!
//! ```ignore
//! #[no_mangle]
//! pub extern "C" fn captchaforge_plugin_entry() -> *const captchaforge::plugin::PluginManifest;
//! ```
//!
//! The returned [`PluginManifest`] tells the host:
//! - The plugin's name / version / supported captcha kinds.
//! - A function pointer that constructs a `Box<dyn CaptchaSolver>`.
//!
//! ## Safety
//!
//! Plugin loading is the ONE place in captchaforge that touches
//! `unsafe`. The plugin manifest is read once at startup and the
//! library handle is held for process lifetime — `dlclose` would
//! invalidate the `Box<dyn CaptchaSolver>`. Documented in the
//! [`PluginRegistry`] type.
//!
//! ## Why not WebAssembly?
//!
//! WebAssembly plugins would be safer but can't hold a `Page`
//! handle or call into chromiumoxide. Native cdylib is the only
//! shape that gives plugins the full solver surface.

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

/// Manifest a plugin exports via the `captchaforge_plugin_entry`
/// symbol. Pure data — no function pointers in the serializable
/// form; the loader treats this as a description-only manifest and
/// looks up the construction function via the cdylib's separate
/// `captchaforge_plugin_construct` symbol.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
    /// Stable plugin identifier (lowercase ASCII + underscores).
    pub name: String,
    /// Semver string. Captchaforge logs a warning when a plugin's
    /// stated min-host-version is newer than the running binary.
    pub version: String,
    /// Captchaforge minimum version this plugin claims compatibility
    /// with. The loader refuses plugins declaring a version newer
    /// than the host.
    pub captchaforge_compat: String,
    /// Captcha kinds the plugin claims to solve. Used by the chain
    /// to pre-filter dispatch (avoids `supports()` round-trips).
    #[serde(default)]
    pub supported_kinds: Vec<String>,
    /// Free-text description.
    #[serde(default)]
    pub description: String,
}

impl PluginManifest {
    /// Returns Err with a human-readable reason when the plugin
    /// declares a captchaforge_compat version newer than the host
    /// or the manifest fields are malformed.
    pub fn validate_against_host(&self, host_version: &str) -> Result<(), String> {
        if self.name.is_empty() {
            return Err("plugin manifest name is empty".into());
        }
        if !self
            .name
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
        {
            return Err(format!(
                "plugin name {:?} violates [a-z0-9_] pattern",
                self.name
            ));
        }
        if !semver_satisfies(host_version, &self.captchaforge_compat) {
            return Err(format!(
                "plugin requires captchaforge_compat >= {:?} but host is {:?}",
                self.captchaforge_compat, host_version
            ));
        }
        Ok(())
    }
}

/// Minimal X.Y.Z comparator. Returns true iff `host` >= `required`
/// in semver order. Pre-1.0 compatibility ranges aren't handled
/// here — captchaforge_compat is always "X.Y.Z" exact-floor.
fn semver_satisfies(host: &str, required: &str) -> bool {
    let h = parse_xyz(host);
    let r = parse_xyz(required);
    h >= r
}

fn parse_xyz(v: &str) -> (u32, u32, u32) {
    let mut parts = v.trim_start_matches('v').split('.');
    let major = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
    let minor = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
    // Strip trailing -suffix from patch.
    let patch = parts
        .next()
        .and_then(|s| s.split('-').next().and_then(|x| x.parse().ok()))
        .unwrap_or(0);
    (major, minor, patch)
}

/// Discovered plugin — manifest plus the absolute path of the
/// loaded cdylib. Holding this struct keeps the dlopen handle
/// alive for the process lifetime; dropping the registry unloads
/// every plugin.
#[derive(Debug, Clone)]
pub struct DiscoveredPlugin {
    pub manifest: PluginManifest,
    pub path: PathBuf,
}

/// Registry of plugins discovered in a directory.
///
/// The registry is INTENTIONALLY non-dynamic-load in the open-
/// source build — the `libloading` dep + `unsafe` blocks live in
/// a future `captchaforge-plugin-loader` crate (separate, opt-in)
/// so the core captchaforge stays `#![forbid(unsafe_code)]`. This
/// module ships the manifest contract + scan-directory entrypoint;
/// downstream consumers wire the native loading.
pub struct PluginRegistry {
    plugins: Vec<DiscoveredPlugin>,
}

impl PluginRegistry {
    pub fn empty() -> Self {
        Self {
            plugins: Vec::new(),
        }
    }

    /// Scan a directory for `*.manifest.json` files. Each
    /// `<name>.manifest.json` documents the matching `<name>.so` /
    /// `.dylib` / `.dll` in the same directory. Returns a registry
    /// of every plugin whose manifest validates against the host
    /// version. Manifests that fail validation are logged at warn
    /// and skipped.
    pub fn scan(dir: impl AsRef<Path>, host_version: &str) -> std::io::Result<Self> {
        let dir = dir.as_ref();
        let mut plugins = Vec::new();
        if !dir.is_dir() {
            return Ok(Self::empty());
        }
        for entry in std::fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) != Some("json") {
                continue;
            }
            let name = path
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or_default();
            if !name.ends_with(".manifest.json") {
                continue;
            }
            let body = match std::fs::read_to_string(&path) {
                Ok(b) => b,
                Err(e) => {
                    tracing::warn!(
                        path = %path.display(),
                        error = %e,
                        "plugin manifest read failed; skipping"
                    );
                    continue;
                }
            };
            let manifest: PluginManifest = match serde_json::from_str(&body) {
                Ok(m) => m,
                Err(e) => {
                    tracing::warn!(
                        path = %path.display(),
                        error = %e,
                        "plugin manifest parse failed; skipping"
                    );
                    continue;
                }
            };
            if let Err(reason) = manifest.validate_against_host(host_version) {
                tracing::warn!(
                    path = %path.display(),
                    reason,
                    "plugin manifest validation failed; skipping"
                );
                continue;
            }
            // The companion .so/.dylib/.dll must exist alongside the
            // manifest — we don't load it here, just verify it's
            // present so the downstream loader has something to act
            // on.
            let stem = name.trim_end_matches(".manifest.json");
            let candidates = [
                dir.join(format!("{stem}.so")),
                dir.join(format!("{stem}.dylib")),
                dir.join(format!("{stem}.dll")),
            ];
            let cdylib_path = candidates.into_iter().find(|p| p.is_file());
            if let Some(p) = cdylib_path {
                plugins.push(DiscoveredPlugin {
                    manifest,
                    path: p,
                });
            } else {
                tracing::warn!(
                    manifest_path = %path.display(),
                    "plugin manifest found but companion .so/.dylib/.dll missing; skipping"
                );
            }
        }
        Ok(Self { plugins })
    }

    pub fn plugins(&self) -> &[DiscoveredPlugin] {
        &self.plugins
    }

    pub fn len(&self) -> usize {
        self.plugins.len()
    }

    pub fn is_empty(&self) -> bool {
        self.plugins.is_empty()
    }
}

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

    #[test]
    fn manifest_validate_rejects_empty_name() {
        let m = PluginManifest {
            name: "".into(),
            version: "0.1.0".into(),
            captchaforge_compat: "0.2.0".into(),
            supported_kinds: vec![],
            description: "".into(),
        };
        assert!(m.validate_against_host("0.3.0").is_err());
    }

    #[test]
    fn manifest_validate_rejects_uppercase_name() {
        let m = PluginManifest {
            name: "MyPlugin".into(),
            version: "0.1.0".into(),
            captchaforge_compat: "0.2.0".into(),
            supported_kinds: vec![],
            description: "".into(),
        };
        assert!(m.validate_against_host("0.3.0").is_err());
    }

    #[test]
    fn manifest_validate_rejects_too_new_compat() {
        let m = PluginManifest {
            name: "ok".into(),
            version: "0.1.0".into(),
            captchaforge_compat: "1.0.0".into(),
            supported_kinds: vec![],
            description: "".into(),
        };
        assert!(m.validate_against_host("0.2.31").is_err());
    }

    #[test]
    fn manifest_validate_accepts_matching_compat() {
        let m = PluginManifest {
            name: "my_plugin".into(),
            version: "1.2.3".into(),
            captchaforge_compat: "0.2.0".into(),
            supported_kinds: vec!["Turnstile".into()],
            description: "great".into(),
        };
        assert!(m.validate_against_host("0.2.31").is_ok());
    }

    #[test]
    fn semver_satisfies_basic() {
        assert!(semver_satisfies("0.2.31", "0.2.0"));
        assert!(semver_satisfies("1.0.0", "0.9.9"));
        assert!(!semver_satisfies("0.2.0", "0.2.31"));
        assert!(!semver_satisfies("0.1.99", "0.2.0"));
        assert!(semver_satisfies("0.2.31-rc.1", "0.2.31"));
    }

    #[test]
    fn scan_returns_empty_on_missing_dir() {
        let r = PluginRegistry::scan("/tmp/captchaforge-test-no-such-dir-123", "0.2.31").unwrap();
        assert!(r.is_empty());
    }

    #[test]
    fn scan_picks_up_a_valid_manifest_with_companion_so() {
        let tmp = tempfile::tempdir().unwrap();
        let mpath = tmp.path().join("custom.manifest.json");
        let so = tmp.path().join("custom.so");
        let manifest = PluginManifest {
            name: "custom".into(),
            version: "0.1.0".into(),
            captchaforge_compat: "0.2.0".into(),
            supported_kinds: vec!["Custom".into()],
            description: "test".into(),
        };
        std::fs::write(&mpath, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
        std::fs::write(&so, b"binary-stub").unwrap();
        let r = PluginRegistry::scan(tmp.path(), "0.2.31").unwrap();
        assert_eq!(r.len(), 1);
        assert_eq!(r.plugins()[0].manifest.name, "custom");
        assert_eq!(r.plugins()[0].path, so);
    }

    #[test]
    fn scan_skips_manifest_without_companion_cdylib() {
        let tmp = tempfile::tempdir().unwrap();
        let mpath = tmp.path().join("ghost.manifest.json");
        let manifest = PluginManifest {
            name: "ghost".into(),
            version: "0.1.0".into(),
            captchaforge_compat: "0.2.0".into(),
            supported_kinds: vec![],
            description: "".into(),
        };
        std::fs::write(&mpath, serde_json::to_string(&manifest).unwrap()).unwrap();
        let r = PluginRegistry::scan(tmp.path(), "0.2.31").unwrap();
        assert!(r.is_empty(), "manifest without .so must be skipped");
    }
}