use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdversarialCapture {
pub id: String,
pub vendor: String,
pub status: u16,
#[serde(default)]
pub headers: HashMap<String, String>,
pub body: String,
#[serde(default)]
pub cookie_names: Vec<String>,
pub expected: ExpectedOutcome,
#[serde(default)]
pub notes: String,
pub captured_at_unix: i64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExpectedOutcome {
Recognised,
HardBlocked,
NoCaptcha,
}
#[derive(Debug)]
pub struct AdversarialCorpus {
root: PathBuf,
}
impl AdversarialCorpus {
pub fn open(root: impl AsRef<Path>) -> anyhow::Result<Self> {
let root = root.as_ref().to_path_buf();
std::fs::create_dir_all(&root)?;
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn append(&self, c: &AdversarialCapture) -> anyhow::Result<()> {
let dir = self.root.join(&c.vendor);
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{}.json", c.id));
let body = serde_json::to_vec_pretty(c)?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
pub fn load_vendor(&self, vendor: &str) -> anyhow::Result<Vec<AdversarialCapture>> {
let dir = self.root.join(vendor);
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
match std::fs::read_to_string(entry.path())
.ok()
.and_then(|s| serde_json::from_str::<AdversarialCapture>(&s).ok())
{
Some(c) => out.push(c),
None => tracing::debug!(
path = %entry.path().display(),
"adversarial-corpus entry failed to parse, skipping"
),
}
}
Ok(out)
}
pub fn vendors(&self) -> anyhow::Result<Vec<String>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(&self.root)? {
let entry = entry?;
if entry.path().is_dir() {
if let Some(n) = entry.path().file_name().and_then(|s| s.to_str()) {
out.push(n.to_string());
}
}
}
out.sort();
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn sample(id: &str, vendor: &str, expected: ExpectedOutcome) -> AdversarialCapture {
AdversarialCapture {
id: id.into(),
vendor: vendor.into(),
status: 403,
headers: HashMap::new(),
body: "<html><body><div class=\"cf-turnstile\"></div></body></html>".into(),
cookie_names: vec!["cf_clearance".into()],
expected,
notes: "test capture".into(),
captured_at_unix: 1_700_000_000,
}
}
#[test]
fn append_then_load_round_trip() {
let tmp = tempdir().unwrap();
let corpus = AdversarialCorpus::open(tmp.path()).unwrap();
let c1 = sample("a1", "cloudflare-turnstile", ExpectedOutcome::Recognised);
let c2 = sample("a2", "cloudflare-turnstile", ExpectedOutcome::HardBlocked);
corpus.append(&c1).unwrap();
corpus.append(&c2).unwrap();
let mut back = corpus.load_vendor("cloudflare-turnstile").unwrap();
back.sort_by(|a, b| a.id.cmp(&b.id));
assert_eq!(back.len(), 2);
assert_eq!(back[0].id, "a1");
assert_eq!(back[0].expected, ExpectedOutcome::Recognised);
assert_eq!(back[1].id, "a2");
assert_eq!(back[1].expected, ExpectedOutcome::HardBlocked);
}
#[test]
fn vendors_enumerates_subdirs() {
let tmp = tempdir().unwrap();
let corpus = AdversarialCorpus::open(tmp.path()).unwrap();
corpus
.append(&sample("x", "cf", ExpectedOutcome::Recognised))
.unwrap();
corpus
.append(&sample("y", "hcaptcha", ExpectedOutcome::Recognised))
.unwrap();
let mut vendors = corpus.vendors().unwrap();
vendors.sort();
assert_eq!(vendors, vec!["cf".to_string(), "hcaptcha".to_string()]);
}
#[test]
fn load_vendor_with_no_dir_returns_empty() {
let tmp = tempdir().unwrap();
let corpus = AdversarialCorpus::open(tmp.path()).unwrap();
let entries = corpus.load_vendor("never-captured").unwrap();
assert!(entries.is_empty());
}
#[test]
fn corrupt_capture_is_skipped_not_propagated() {
let tmp = tempdir().unwrap();
let corpus = AdversarialCorpus::open(tmp.path()).unwrap();
std::fs::create_dir_all(tmp.path().join("cf")).unwrap();
std::fs::write(
tmp.path().join("cf").join("garbage.json"),
b"{not json at all",
)
.unwrap();
let entries = corpus.load_vendor("cf").unwrap();
assert!(entries.is_empty());
}
#[test]
fn expected_outcome_serialises_snake_case() {
let r = serde_json::to_string(&ExpectedOutcome::Recognised).unwrap();
let h = serde_json::to_string(&ExpectedOutcome::HardBlocked).unwrap();
let n = serde_json::to_string(&ExpectedOutcome::NoCaptcha).unwrap();
assert_eq!(r, "\"recognised\"");
assert_eq!(h, "\"hard_blocked\"");
assert_eq!(n, "\"no_captcha\"");
}
}