use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FingerprintEntry {
pub vendor: String,
pub fingerprint: String,
pub best_solver: String,
pub wins: u32,
pub attempts: u32,
pub last_seen_unix: i64,
}
impl FingerprintEntry {
pub fn win_rate(&self) -> f32 {
if self.attempts == 0 {
0.0
} else {
self.wins as f32 / self.attempts as f32
}
}
pub fn confidence(&self) -> f32 {
if self.attempts < 5 {
return 0.0;
}
let alpha = (self.wins as f32) + 2.0;
let beta = (self.attempts as f32 - self.wins as f32) + 2.0;
alpha / (alpha + beta)
}
}
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}")
}
pub fn new(cap: usize) -> Self {
Self {
inner: RwLock::new(HashMap::new()),
path: None,
cap: cap.max(1),
min_confidence: 0.6,
}
}
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,
})
}
pub fn with_min_confidence(mut self, threshold: f32) -> Self {
self.min_confidence = threshold.clamp(0.0, 1.0);
self
}
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);
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;
}
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);
}
}
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
}
}
pub fn entries(&self) -> Vec<FingerprintEntry> {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.values()
.cloned()
.collect()
}
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);
}
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,
};
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);
}
}