use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::cookies::CapturedCookie;
use crate::solver::CaptchaType;
#[derive(Debug, Clone, Copy)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub expired_misses: u64,
pub puts: u64,
pub invalidations: u64,
}
impl CacheStats {
pub fn hit_rate(&self) -> Option<f64> {
let lookups = self.hits + self.misses + self.expired_misses;
if lookups == 0 {
None
} else {
Some(self.hits as f64 / lookups as f64)
}
}
}
#[derive(Debug, Clone)]
pub struct CachedToken {
token: String,
method_name: &'static str,
expires_at: Instant,
cookies: Vec<CapturedCookie>,
}
pub struct TokenCache {
inner: Mutex<HashMap<(String, CaptchaType), CachedToken>>,
default_ttl: Duration,
hits: AtomicU64,
misses: AtomicU64,
expired_misses: AtomicU64,
puts: AtomicU64,
invalidations: AtomicU64,
}
impl TokenCache {
pub fn new() -> Self {
Self::with_ttl(Duration::from_secs(60))
}
pub fn with_ttl(default_ttl: Duration) -> Self {
Self {
inner: Mutex::new(HashMap::new()),
default_ttl,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
expired_misses: AtomicU64::new(0),
puts: AtomicU64::new(0),
invalidations: AtomicU64::new(0),
}
}
pub fn get(&self, domain: &str, captcha_type: &CaptchaType) -> Option<CachedToken> {
let map = self.inner.lock().unwrap();
let key = (domain.to_owned(), captcha_type.clone());
match map.get(&key) {
None => {
self.misses.fetch_add(1, Ordering::Relaxed);
None
}
Some(entry) if entry.expires_at > Instant::now() => {
self.hits.fetch_add(1, Ordering::Relaxed);
Some(entry.clone())
}
Some(_) => {
self.expired_misses.fetch_add(1, Ordering::Relaxed);
None
}
}
}
pub fn get_token(&self, domain: &str, captcha_type: &CaptchaType) -> Option<String> {
self.get(domain, captcha_type).map(|e| e.token)
}
pub fn put(
&self,
domain: &str,
captcha_type: &CaptchaType,
token: String,
method_name: &'static str,
) {
self.put_with_ttl(domain, captcha_type, token, method_name, self.default_ttl);
}
pub fn put_with_ttl(
&self,
domain: &str,
captcha_type: &CaptchaType,
token: String,
method_name: &'static str,
ttl: Duration,
) {
self.put_full(domain, captcha_type, token, method_name, ttl, Vec::new());
}
pub fn put_full(
&self,
domain: &str,
captcha_type: &CaptchaType,
token: String,
method_name: &'static str,
ttl: Duration,
cookies: Vec<CapturedCookie>,
) {
let mut map = self.inner.lock().unwrap();
map.insert(
(domain.to_owned(), captcha_type.clone()),
CachedToken {
token,
method_name,
expires_at: Instant::now() + ttl,
cookies,
},
);
self.puts.fetch_add(1, Ordering::Relaxed);
}
pub fn invalidate(&self, domain: &str, captcha_type: &CaptchaType) {
let mut map = self.inner.lock().unwrap();
if map
.remove(&(domain.to_owned(), captcha_type.clone()))
.is_some()
{
self.invalidations.fetch_add(1, Ordering::Relaxed);
}
}
pub fn clear(&self) {
self.inner.lock().unwrap().clear();
}
pub fn len(&self) -> usize {
self.inner.lock().unwrap().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn ttl(&self) -> Duration {
self.default_ttl
}
pub fn stats(&self) -> CacheStats {
CacheStats {
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
expired_misses: self.expired_misses.load(Ordering::Relaxed),
puts: self.puts.load(Ordering::Relaxed),
invalidations: self.invalidations.load(Ordering::Relaxed),
}
}
pub fn reset_stats(&self) {
self.hits.store(0, Ordering::Relaxed);
self.misses.store(0, Ordering::Relaxed);
self.expired_misses.store(0, Ordering::Relaxed);
self.puts.store(0, Ordering::Relaxed);
self.invalidations.store(0, Ordering::Relaxed);
}
}
impl CachedToken {
pub fn token(&self) -> &str {
&self.token
}
pub fn method_name(&self) -> &'static str {
self.method_name
}
pub fn cookies(&self) -> &[CapturedCookie] {
&self.cookies
}
}
impl Default for TokenCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn miss_returns_none() {
let c = TokenCache::new();
assert!(c
.get("example.com", &CaptchaType::CloudflareTurnstile)
.is_none());
}
#[test]
fn hit_returns_token_within_ttl() {
let c = TokenCache::new();
c.put(
"example.com",
&CaptchaType::CloudflareTurnstile,
"tok123".into(),
"BehavioralCaptchaSolver",
);
let got = c.get_token("example.com", &CaptchaType::CloudflareTurnstile);
assert_eq!(got.as_deref(), Some("tok123"));
}
#[test]
fn expired_entry_returns_none() {
let c = TokenCache::with_ttl(Duration::from_millis(1));
c.put(
"example.com",
&CaptchaType::HCaptcha,
"tok".into(),
"VlmCaptchaSolver",
);
std::thread::sleep(Duration::from_millis(5));
assert!(c.get("example.com", &CaptchaType::HCaptcha).is_none());
}
#[test]
fn put_replaces_existing_entry() {
let c = TokenCache::new();
c.put(
"x.test",
&CaptchaType::HCaptcha,
"old".into(),
"VlmCaptchaSolver",
);
c.put(
"x.test",
&CaptchaType::HCaptcha,
"new".into(),
"VlmCaptchaSolver",
);
assert_eq!(
c.get_token("x.test", &CaptchaType::HCaptcha).as_deref(),
Some("new")
);
assert_eq!(c.len(), 1, "replace must not grow the map");
}
#[test]
fn invalidate_drops_only_the_targeted_entry() {
let c = TokenCache::new();
c.put("a.test", &CaptchaType::HCaptcha, "ta".into(), "Vlm");
c.put("b.test", &CaptchaType::HCaptcha, "tb".into(), "Vlm");
c.invalidate("a.test", &CaptchaType::HCaptcha);
assert!(c.get("a.test", &CaptchaType::HCaptcha).is_none());
assert!(c.get("b.test", &CaptchaType::HCaptcha).is_some());
}
#[test]
fn different_captcha_types_on_same_domain_are_isolated() {
let c = TokenCache::new();
c.put("d.test", &CaptchaType::HCaptcha, "h".into(), "Vlm");
c.put(
"d.test",
&CaptchaType::CloudflareTurnstile,
"t".into(),
"Beh",
);
assert_eq!(
c.get_token("d.test", &CaptchaType::HCaptcha).as_deref(),
Some("h")
);
assert_eq!(
c.get_token("d.test", &CaptchaType::CloudflareTurnstile)
.as_deref(),
Some("t")
);
}
#[test]
fn put_with_ttl_overrides_default() {
let c = TokenCache::with_ttl(Duration::from_secs(10));
c.put_with_ttl(
"x.test",
&CaptchaType::HCaptcha,
"tok".into(),
"Vlm",
Duration::from_millis(1),
);
std::thread::sleep(Duration::from_millis(5));
assert!(
c.get("x.test", &CaptchaType::HCaptcha).is_none(),
"explicit short TTL wins"
);
}
#[test]
fn put_with_cookies_round_trips_on_hit() {
let c = TokenCache::new();
let cookie = CapturedCookie {
name: "datadome".into(),
value: "abc".into(),
domain: "x.test".into(),
path: "/".into(),
expires: None,
secure: true,
http_only: true,
same_site: None,
};
c.put_full(
"x.test",
&CaptchaType::CloudflareTurnstile,
"tok".into(),
"Behavioral",
Duration::from_secs(60),
vec![cookie.clone()],
);
let entry = c.get("x.test", &CaptchaType::CloudflareTurnstile).unwrap();
assert_eq!(entry.cookies().len(), 1);
assert_eq!(entry.cookies()[0].name, "datadome");
}
#[test]
fn put_without_cookies_returns_empty_cookies_on_hit() {
let c = TokenCache::new();
c.put("x.test", &CaptchaType::HCaptcha, "t".into(), "Vlm");
let entry = c.get("x.test", &CaptchaType::HCaptcha).unwrap();
assert!(entry.cookies().is_empty());
}
#[test]
fn stats_initialise_to_zero() {
let c = TokenCache::new();
let s = c.stats();
assert_eq!(s.hits, 0);
assert_eq!(s.misses, 0);
assert_eq!(s.expired_misses, 0);
assert_eq!(s.puts, 0);
assert_eq!(s.invalidations, 0);
assert!(s.hit_rate().is_none(), "no lookups → no rate");
}
#[test]
fn stats_count_each_event_class_separately() {
let c = TokenCache::new();
c.put("a.test", &CaptchaType::HCaptcha, "t".into(), "Vlm");
assert_eq!(c.stats().puts, 1);
let _ = c.get("a.test", &CaptchaType::HCaptcha);
let _ = c.get("a.test", &CaptchaType::HCaptcha);
assert_eq!(c.stats().hits, 2);
let _ = c.get("never.test", &CaptchaType::HCaptcha);
assert_eq!(c.stats().misses, 1);
c.invalidate("a.test", &CaptchaType::HCaptcha);
assert_eq!(c.stats().invalidations, 1);
let s = c.stats();
let r = s.hit_rate().unwrap();
assert!(
(r - 2.0 / 3.0).abs() < 0.001,
"expected 0.667 hit rate; got {r}"
);
}
#[test]
fn stats_track_expired_misses_distinct_from_misses() {
let c = TokenCache::with_ttl(Duration::from_millis(1));
c.put("x.test", &CaptchaType::HCaptcha, "t".into(), "Vlm");
std::thread::sleep(Duration::from_millis(5));
let _ = c.get("x.test", &CaptchaType::HCaptcha);
let s = c.stats();
assert_eq!(s.misses, 0, "key existed → not a plain miss");
assert_eq!(s.expired_misses, 1, "key existed but expired");
}
#[test]
fn invalidate_on_missing_key_does_not_count() {
let c = TokenCache::new();
c.invalidate("missing.test", &CaptchaType::HCaptcha);
assert_eq!(c.stats().invalidations, 0);
}
#[test]
fn reset_stats_zeroes_counters_but_keeps_entries() {
let c = TokenCache::new();
c.put("a.test", &CaptchaType::HCaptcha, "t".into(), "Vlm");
let _ = c.get("a.test", &CaptchaType::HCaptcha);
assert_eq!(c.stats().hits, 1);
c.reset_stats();
let s = c.stats();
assert_eq!(s.hits, 0);
assert_eq!(s.puts, 0);
assert!(c.get("a.test", &CaptchaType::HCaptcha).is_some());
}
#[test]
fn clear_removes_all_entries() {
let c = TokenCache::new();
c.put("a.test", &CaptchaType::HCaptcha, "ta".into(), "Vlm");
c.put("b.test", &CaptchaType::HCaptcha, "tb".into(), "Vlm");
assert_eq!(c.len(), 2);
c.clear();
assert!(c.is_empty());
}
}