use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub captchaforge_compat: String,
#[serde(default)]
pub supported_kinds: Vec<String>,
#[serde(default)]
pub description: String,
}
impl PluginManifest {
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(())
}
}
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);
let patch = parts
.next()
.and_then(|s| s.split('-').next().and_then(|x| x.parse().ok()))
.unwrap_or(0);
(major, minor, patch)
}
#[derive(Debug, Clone)]
pub struct DiscoveredPlugin {
pub manifest: PluginManifest,
pub path: PathBuf,
}
pub struct PluginRegistry {
plugins: Vec<DiscoveredPlugin>,
}
impl PluginRegistry {
pub fn empty() -> Self {
Self {
plugins: Vec::new(),
}
}
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;
}
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");
}
}