use std::collections::HashMap; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::net::IpAddr; use std::path::Path; use std::sync::{Mutex, RwLock}; use chrono::{DateTime, Utc}; use rusqlite::{Connection, params}; use uuid::Uuid; use crate::error::CaptchaError; use crate::puzzle::types::Challenge; use crate::risk::behavior::BEHAVIOR_DUPLICATE_WINDOW_SECS; use crate::risk::signals::RATE_SUSTAINED_WINDOW_SECS; use crate::site::types::{Site, SitePolicy}; use super::Store; struct RateWindow { count: u32, window_start: i64, } /// Number of lock stripes for each rate map. Every `GET /v1/puzzle` bumps a /// per-IP and a per-site counter; a single `RwLock` per map would /// serialise every request on one writer. Striping by key hash spreads that /// contention across independent locks. Power of two so the modulo is cheap. const RATE_SHARDS: usize = 16; /// Rate-window map split across `RATE_SHARDS` independently-locked stripes. /// Each key lands in one stripe by hash, so concurrent requests for different /// keys rarely contend. The critical section stays tiny (an entry lookup + a /// counter bump), matching the previous single-lock behaviour per key. struct ShardedRates { shards: [RwLock>; RATE_SHARDS], /// Length of one counting window, in seconds. Per-instance because the /// maps count different things: issuance rate over 60s, repeated /// behaviour blobs over `BEHAVIOR_DUPLICATE_WINDOW_SECS`. window_secs: i64, } impl ShardedRates { fn new(window_secs: i64) -> Self { Self { shards: std::array::from_fn(|_| RwLock::new(HashMap::new())), window_secs, } } fn shard(&self, key: &K) -> &RwLock> { let mut hasher = DefaultHasher::new(); key.hash(&mut hasher); &self.shards[(hasher.finish() as usize) % RATE_SHARDS] } /// Increment the key's counter within the current window, resetting it /// when the window has rolled over. Returns the new count. fn increment(&self, key: K) -> Result { let mut map = self.shard(&key).write().map_err(lock_err)?; let now = Utc::now().timestamp(); let entry = map.entry(key).or_insert(RateWindow { count: 0, window_start: now, }); if now - entry.window_start >= self.window_secs { entry.count = 0; entry.window_start = now; } entry.count += 1; Ok(entry.count) } /// Drop windows older than twice this map's window across every stripe. fn retain_fresh(&self, now_ts: i64) -> Result<(), CaptchaError> { for shard in &self.shards { let mut map = shard.write().map_err(lock_err)?; map.retain(|_, w| now_ts - w.window_start < self.window_secs * 2); } Ok(()) } } pub struct InMemoryStore { challenges: RwLock>, sites_by_key: RwLock>, sites_by_secret: RwLock>, ip_rates: ShardedRates, /// The same per-IP key over the 15 min window. Entries live for two /// windows before the sweeper reclaims them, so this map holds every /// distinct source seen in the last half hour — a few dozen bytes each. ip_rates_sustained: ShardedRates, site_rates: ShardedRates, /// Occurrences of one verify-time behaviour blob per site, keyed on /// `(site_key, behavior_fingerprint)`. Same shape as the counters above, /// on a longer window. Entries are only created after a *valid* proof of /// work, so growing this map costs an attacker one solve per entry, and /// `cleanup_expired` reclaims it on the same sweep as the rate windows. behavior_blobs: ShardedRates<(Uuid, u64)>, /// Optional write-through persistence for the sites table. Challenges /// and rate windows stay in-memory — they're cheap to lose on restart. /// Sites can't be: integrators store the secret_key client-side, so a /// restart that wipes them means every embedded captcha breaks. site_persistence: Option>, } const RATE_WINDOW_SECS: i64 = 60; const SITES_SCHEMA: &str = r" CREATE TABLE IF NOT EXISTS sites ( site_key TEXT PRIMARY KEY, secret_key TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TEXT NOT NULL, allowed_origins TEXT NOT NULL DEFAULT '', policy TEXT ); "; impl InMemoryStore { pub fn new() -> Self { Self { challenges: RwLock::new(HashMap::new()), sites_by_key: RwLock::new(HashMap::new()), sites_by_secret: RwLock::new(HashMap::new()), ip_rates: ShardedRates::new(RATE_WINDOW_SECS), ip_rates_sustained: ShardedRates::new(RATE_SUSTAINED_WINDOW_SECS), site_rates: ShardedRates::new(RATE_WINDOW_SECS), behavior_blobs: ShardedRates::new(BEHAVIOR_DUPLICATE_WINDOW_SECS), site_persistence: None, } } /// Open or create a SQLite database at `path` for site persistence, /// load any existing sites into the in-memory map, and keep the /// connection for write-through inserts. pub fn with_site_persistence(path: impl AsRef) -> Result { let conn = Connection::open(path.as_ref()) .map_err(|e| CaptchaError::Storage(format!("open site db: {e}")))?; conn.execute_batch(SITES_SCHEMA) .map_err(|e| CaptchaError::Storage(format!("init site schema: {e}")))?; migrate_add_site_column(&conn, "allowed_origins", "TEXT NOT NULL DEFAULT ''")?; // Nullable: NULL means "no overrides", which is what every site // predating per-site policy has and what an emptied policy writes back. migrate_add_site_column(&conn, "policy", "TEXT")?; let sites = load_all_sites(&conn)?; let store = Self::new(); { let mut by_key = store.sites_by_key.write().map_err(lock_err)?; let mut by_secret = store.sites_by_secret.write().map_err(lock_err)?; for site in sites { by_secret.insert(site.secret_key.clone(), site.site_key); by_key.insert(site.site_key, site); } } Ok(Self { site_persistence: Some(Mutex::new(conn)), ..store }) } /// Number of challenges currently held in memory. Used by the puzzle /// handler as a global issuance backstop (`MAX_ACTIVE_CHALLENGES`) so a /// distributed flood can't grow the map unbounded between cleanup sweeps. pub fn challenge_count(&self) -> usize { self.challenges.read().map(|m| m.len()).unwrap_or_default() } /// Number of sites currently loaded. Useful at boot to log how many /// were restored from disk. pub fn site_count(&self) -> usize { self.sites_by_key .read() .map(|m| m.len()) .unwrap_or_default() } /// Snapshot of every registered site, sorted by `created_at` ascending. /// Used by the admin dashboard. Cloning is cheap (a few strings per site) /// and keeps the read lock held for as little time as possible. pub fn list_sites(&self) -> Result, CaptchaError> { let map = self.sites_by_key.read().map_err(lock_err)?; let mut out: Vec = map.values().cloned().collect(); out.sort_by_key(|s| s.created_at); Ok(out) } } /// Add a column to the `sites` table when a database created by an older /// build doesn't have it yet. SQLite has no `ADD COLUMN IF NOT EXISTS`, so /// probe `pragma_table_info` first and only ALTER when the column is absent — /// a fresh database created from the current `SITES_SCHEMA` already has every /// column, so this is a no-op there. Mirrors the in-place migration pattern in /// `dashboard/log.rs`. fn migrate_add_site_column( conn: &Connection, column: &str, definition: &str, ) -> Result<(), CaptchaError> { let has_column: bool = conn .prepare("SELECT 1 FROM pragma_table_info('sites') WHERE name = ?1") .and_then(|mut stmt| stmt.exists([column])) .map_err(|e| CaptchaError::Storage(format!("probe sites schema: {e}")))?; if !has_column { // `column`/`definition` are compile-time literals from the call sites // below, never user input — SQLite can't bind an identifier anyway. conn.execute( &format!("ALTER TABLE sites ADD COLUMN {column} {definition}"), [], ) .map_err(|e| CaptchaError::Storage(format!("add {column} column: {e}")))?; } Ok(()) } fn load_all_sites(conn: &Connection) -> Result, CaptchaError> { let mut stmt = conn .prepare( "SELECT site_key, secret_key, name, created_at, allowed_origins, policy FROM sites", ) .map_err(|e| CaptchaError::Storage(format!("prepare load sites: {e}")))?; let rows = stmt .query_map([], |row| { let key_str: String = row.get(0)?; let secret: String = row.get(1)?; let name: String = row.get(2)?; let created_str: String = row.get(3)?; let origins_str: String = row.get(4)?; let policy_json: Option = row.get(5)?; Ok((key_str, secret, name, created_str, origins_str, policy_json)) }) .map_err(|e| CaptchaError::Storage(format!("query sites: {e}")))?; let mut out = Vec::new(); for row in rows { let (key_str, secret_key, name, created_str, origins_str, policy_json) = row.map_err(|e| CaptchaError::Storage(format!("read site row: {e}")))?; let site_key = Uuid::parse_str(&key_str) .map_err(|e| CaptchaError::Storage(format!("bad site_key in db: {e}")))?; let created_at = DateTime::parse_from_rfc3339(&created_str) .map_err(|e| CaptchaError::Storage(format!("bad created_at in db: {e}")))? .with_timezone(&Utc); // A policy row we can't parse must not be silently downgraded to // "no overrides": that would quietly restore the global (looser) // thresholds for a site whose operator deliberately tightened them. // Refuse to boot instead, the same stance `AppConfig::validated` takes. let policy = match policy_json { Some(json) => serde_json::from_str(&json).map_err(|e| { CaptchaError::Storage(format!("bad policy for site {key_str} in db: {e}")) })?, None => SitePolicy::default(), }; out.push(Site { site_key, secret_key, name, created_at, // Space-joined; entries never contain whitespace (normalized at // provisioning time), so splitting on whitespace is lossless. allowed_origins: origins_str.split_whitespace().map(String::from).collect(), policy, }); } Ok(out) } /// Serialize a policy for the `policy` column: `None` (SQL NULL) when nothing /// is overridden, so an untouched site stays visibly untouched on disk and the /// column reads the same as it does for sites created before policies existed. fn policy_column(policy: &SitePolicy) -> Result, CaptchaError> { if policy.is_empty() { return Ok(None); } serde_json::to_string(policy) .map(Some) .map_err(|e| CaptchaError::Storage(format!("serialize site policy: {e}"))) } fn persist_site(conn: &Connection, site: &Site) -> Result<(), CaptchaError> { conn.execute( "INSERT INTO sites (site_key, secret_key, name, created_at, allowed_origins, policy) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ site.site_key.to_string(), site.secret_key, site.name, site.created_at.to_rfc3339(), site.allowed_origins.join(" "), policy_column(&site.policy)?, ], ) .map_err(|e| CaptchaError::Storage(format!("insert site: {e}")))?; Ok(()) } impl Default for InMemoryStore { fn default() -> Self { Self::new() } } impl Store for InMemoryStore { async fn store_challenge(&self, challenge: &Challenge) -> Result<(), CaptchaError> { let mut map = self.challenges.write().map_err(lock_err)?; map.insert(challenge.id, challenge.clone()); Ok(()) } async fn get_challenge(&self, id: &Uuid) -> Result, CaptchaError> { let map = self.challenges.read().map_err(lock_err)?; Ok(map.get(id).cloned()) } async fn delete_challenge(&self, id: &Uuid) -> Result<(), CaptchaError> { let mut map = self.challenges.write().map_err(lock_err)?; map.remove(id); Ok(()) } async fn consume_challenge(&self, challenge_id: &Uuid) -> Result<(), CaptchaError> { // Single-use is enforced by removal: exactly one caller removes the // challenge and passes; concurrent/replayed submits get `None` → // `ChallengeNotFound` (404). let mut map = self.challenges.write().map_err(lock_err)?; match map.remove(challenge_id) { Some(_) => Ok(()), None => Err(CaptchaError::ChallengeNotFound), } } async fn register_failed_attempt( &self, challenge_id: &Uuid, max_attempts: u32, ) -> Result { if max_attempts == 0 { return Ok(false); } let mut map = self.challenges.write().map_err(lock_err)?; if let Some(challenge) = map.get_mut(challenge_id) { challenge.attempts += 1; if challenge.attempts >= max_attempts { map.remove(challenge_id); return Ok(true); } } Ok(false) } async fn store_site(&self, site: &Site) -> Result<(), CaptchaError> { // Hold the in-memory write locks across the SQLite write so persistence // and memory can't diverge under concurrent mutations of the same site // (two admin writes committing to disk in one order but to memory in // the other). Lock order is by_key → by_secret everywhere. Persist // inside the critical section, first, so a failed disk write (e.g. // UNIQUE collision on a regenerated secret) leaves memory untouched. let mut by_key = self.sites_by_key.write().map_err(lock_err)?; let mut by_secret = self.sites_by_secret.write().map_err(lock_err)?; if let Some(persist) = &self.site_persistence { let conn = persist.lock().map_err(lock_err)?; persist_site(&conn, site)?; } by_key.insert(site.site_key, site.clone()); by_secret.insert(site.secret_key.clone(), site.site_key); Ok(()) } async fn get_site_by_key(&self, site_key: &Uuid) -> Result, CaptchaError> { let map = self.sites_by_key.read().map_err(lock_err)?; Ok(map.get(site_key).cloned()) } async fn get_site_by_secret(&self, secret: &str) -> Result, CaptchaError> { let site_key = { let by_secret = self.sites_by_secret.read().map_err(lock_err)?; by_secret.get(secret).copied() }; match site_key { Some(key) => self.get_site_by_key(&key).await, None => Ok(None), } } async fn rotate_site_secret( &self, site_key: &Uuid, new_secret: String, ) -> Result<(), CaptchaError> { // One critical section (see store_site): resolve NotFound from memory, // persist inside the lock, then swap in-memory. On UNIQUE collision // (vanishingly rare for 32-byte random secrets) the persist fails and // memory is left unchanged. let mut by_key = self.sites_by_key.write().map_err(lock_err)?; let mut by_secret = self.sites_by_secret.write().map_err(lock_err)?; let Some(site) = by_key.get_mut(site_key) else { return Err(CaptchaError::NotFound); }; if let Some(persist) = &self.site_persistence { let conn = persist.lock().map_err(lock_err)?; let updated = conn .execute( "UPDATE sites SET secret_key = ?1 WHERE site_key = ?2", params![new_secret, site_key.to_string()], ) .map_err(|e| CaptchaError::Storage(format!("rotate site secret: {e}")))?; if updated == 0 { // Memory has the site but the DB row is missing — a pre-existing // divergence we should never hit. Surface it; memory is untouched. return Err(CaptchaError::Storage( "site in memory but missing from db".into(), )); } } let old_secret = std::mem::replace(&mut site.secret_key, new_secret.clone()); by_secret.remove(&old_secret); by_secret.insert(new_secret, *site_key); Ok(()) } async fn update_site_origins( &self, site_key: &Uuid, origins: Vec, ) -> Result<(), CaptchaError> { // One critical section (see store_site): resolve NotFound from memory, // persist inside the lock, then swap in-memory. let mut by_key = self.sites_by_key.write().map_err(lock_err)?; let Some(site) = by_key.get_mut(site_key) else { return Err(CaptchaError::NotFound); }; if let Some(persist) = &self.site_persistence { let conn = persist.lock().map_err(lock_err)?; let updated = conn .execute( "UPDATE sites SET allowed_origins = ?1 WHERE site_key = ?2", params![origins.join(" "), site_key.to_string()], ) .map_err(|e| CaptchaError::Storage(format!("update site origins: {e}")))?; if updated == 0 { return Err(CaptchaError::Storage( "site in memory but missing from db".into(), )); } } site.allowed_origins = origins; Ok(()) } async fn update_site_name(&self, site_key: &Uuid, name: String) -> Result<(), CaptchaError> { // One critical section (see store_site): resolve NotFound from memory, // persist inside the lock, then swap in-memory. The name is not indexed // — nothing looks a site up by it — so only `sites_by_key` is touched. let mut by_key = self.sites_by_key.write().map_err(lock_err)?; let Some(site) = by_key.get_mut(site_key) else { return Err(CaptchaError::NotFound); }; if let Some(persist) = &self.site_persistence { let conn = persist.lock().map_err(lock_err)?; let updated = conn .execute( "UPDATE sites SET name = ?1 WHERE site_key = ?2", params![name, site_key.to_string()], ) .map_err(|e| CaptchaError::Storage(format!("update site name: {e}")))?; if updated == 0 { return Err(CaptchaError::Storage( "site in memory but missing from db".into(), )); } } site.name = name; Ok(()) } async fn update_site_policy( &self, site_key: &Uuid, policy: SitePolicy, ) -> Result<(), CaptchaError> { // One critical section (see store_site): resolve NotFound from memory, // persist inside the lock, then swap in-memory. let mut by_key = self.sites_by_key.write().map_err(lock_err)?; let Some(site) = by_key.get_mut(site_key) else { return Err(CaptchaError::NotFound); }; if let Some(persist) = &self.site_persistence { let conn = persist.lock().map_err(lock_err)?; let updated = conn .execute( "UPDATE sites SET policy = ?1 WHERE site_key = ?2", params![policy_column(&policy)?, site_key.to_string()], ) .map_err(|e| CaptchaError::Storage(format!("update site policy: {e}")))?; if updated == 0 { return Err(CaptchaError::Storage( "site in memory but missing from db".into(), )); } } site.policy = policy; Ok(()) } async fn delete_site(&self, site_key: &Uuid) -> Result<(), CaptchaError> { // One critical section (see store_site): resolve NotFound from memory, // delete from disk inside the lock, then remove from memory. let mut by_key = self.sites_by_key.write().map_err(lock_err)?; let mut by_secret = self.sites_by_secret.write().map_err(lock_err)?; if !by_key.contains_key(site_key) { return Err(CaptchaError::NotFound); } if let Some(persist) = &self.site_persistence { let conn = persist.lock().map_err(lock_err)?; let deleted = conn .execute( "DELETE FROM sites WHERE site_key = ?1", params![site_key.to_string()], ) .map_err(|e| CaptchaError::Storage(format!("delete site: {e}")))?; if deleted == 0 { return Err(CaptchaError::Storage( "site in memory but missing from db".into(), )); } } let site = by_key .remove(site_key) .expect("presence checked under the same lock"); by_secret.remove(&site.secret_key); Ok(()) } async fn increment_ip_count(&self, ip: &IpAddr) -> Result { self.ip_rates.increment(*ip) } async fn increment_ip_count_sustained(&self, ip: &IpAddr) -> Result { self.ip_rates_sustained.increment(*ip) } async fn increment_site_count(&self, site_key: &Uuid) -> Result { self.site_rates.increment(*site_key) } async fn increment_behavior_blob_count( &self, site_key: &Uuid, fingerprint: u64, ) -> Result { self.behavior_blobs.increment((*site_key, fingerprint)) } async fn cleanup_expired(&self) -> Result<(), CaptchaError> { let now = Utc::now(); // Clean expired challenges { let mut map = self.challenges.write().map_err(lock_err)?; map.retain(|_, c| c.expires_at > now); } // Clean stale rate windows across every stripe let ts = now.timestamp(); self.ip_rates.retain_fresh(ts)?; self.ip_rates_sustained.retain_fresh(ts)?; self.site_rates.retain_fresh(ts)?; self.behavior_blobs.retain_fresh(ts)?; Ok(()) } } fn lock_err(_: T) -> CaptchaError { CaptchaError::Storage("Lock poisoned".into()) } #[cfg(test)] mod tests { use super::*; use crate::puzzle::types::Algorithm; use chrono::Duration; fn make_challenge(site_key: Uuid, ttl_secs: i64) -> Challenge { let now = Utc::now(); Challenge { id: Uuid::new_v4(), site_key, algorithm: Algorithm::Sha256, prefix: "deadbeef".into(), difficulty: 8, created_at: now, dwell_since: now, expires_at: now + Duration::seconds(ttl_secs), attempts: 0, issued_to: None, } } fn make_site() -> Site { // Random secret so two sites in the same test don't collide on // the UNIQUE constraint when persistence is enabled. let secret = format!("secret-{}", Uuid::new_v4().simple()); Site { site_key: Uuid::new_v4(), secret_key: secret, name: "Test Site".into(), created_at: Utc::now(), allowed_origins: Vec::new(), policy: SitePolicy::default(), } } #[tokio::test] async fn test_challenge_crud() { let store = InMemoryStore::new(); let challenge = make_challenge(Uuid::new_v4(), 300); store.store_challenge(&challenge).await.unwrap(); let fetched = store.get_challenge(&challenge.id).await.unwrap().unwrap(); assert_eq!(fetched.id, challenge.id); store.delete_challenge(&challenge.id).await.unwrap(); assert!(store.get_challenge(&challenge.id).await.unwrap().is_none()); } #[tokio::test] async fn test_consume_challenge_removes_once() { let store = InMemoryStore::new(); let challenge = make_challenge(Uuid::new_v4(), 300); store.store_challenge(&challenge).await.unwrap(); store.consume_challenge(&challenge.id).await.unwrap(); assert!(store.get_challenge(&challenge.id).await.unwrap().is_none()); let second = store.consume_challenge(&challenge.id).await; assert!(matches!(second, Err(CaptchaError::ChallengeNotFound))); } #[tokio::test] async fn test_register_failed_attempt_evicts_at_cap() { let store = InMemoryStore::new(); let challenge = make_challenge(Uuid::new_v4(), 300); store.store_challenge(&challenge).await.unwrap(); // Below the cap the challenge stays live. assert!( !store .register_failed_attempt(&challenge.id, 3) .await .unwrap() ); assert!( !store .register_failed_attempt(&challenge.id, 3) .await .unwrap() ); assert!(store.get_challenge(&challenge.id).await.unwrap().is_some()); // The third failed attempt reaches the cap and evicts it. assert!( store .register_failed_attempt(&challenge.id, 3) .await .unwrap() ); assert!(store.get_challenge(&challenge.id).await.unwrap().is_none()); } #[tokio::test] async fn test_register_failed_attempt_disabled_when_zero() { let store = InMemoryStore::new(); let challenge = make_challenge(Uuid::new_v4(), 300); store.store_challenge(&challenge).await.unwrap(); // max_attempts = 0 disables the cap: never evicts, never increments. for _ in 0..1000 { assert!( !store .register_failed_attempt(&challenge.id, 0) .await .unwrap() ); } assert!(store.get_challenge(&challenge.id).await.unwrap().is_some()); } #[tokio::test] async fn test_register_failed_attempt_missing_challenge() { let store = InMemoryStore::new(); // A never-existed / already-consumed challenge is a no-op, not an error. assert!( !store .register_failed_attempt(&Uuid::new_v4(), 3) .await .unwrap() ); } #[tokio::test] async fn test_challenge_count() { let store = InMemoryStore::new(); assert_eq!(store.challenge_count(), 0); let a = make_challenge(Uuid::new_v4(), 300); let b = make_challenge(Uuid::new_v4(), 300); store.store_challenge(&a).await.unwrap(); store.store_challenge(&b).await.unwrap(); assert_eq!(store.challenge_count(), 2); store.consume_challenge(&a.id).await.unwrap(); assert_eq!(store.challenge_count(), 1); } #[tokio::test] async fn test_site_crud() { let store = InMemoryStore::new(); let site = make_site(); store.store_site(&site).await.unwrap(); let by_key = store .get_site_by_key(&site.site_key) .await .unwrap() .unwrap(); assert_eq!(by_key.name, site.name); let by_secret = store .get_site_by_secret(&site.secret_key) .await .unwrap() .unwrap(); assert_eq!(by_secret.site_key, site.site_key); } #[tokio::test] async fn test_site_not_found() { let store = InMemoryStore::new(); assert!( store .get_site_by_key(&Uuid::new_v4()) .await .unwrap() .is_none() ); assert!( store .get_site_by_secret("nonexistent") .await .unwrap() .is_none() ); } #[tokio::test] async fn test_sustained_ip_counter_is_independent_of_the_minute_counter() { let store = InMemoryStore::new(); let ip: IpAddr = "127.0.0.1".parse().unwrap(); for _ in 0..3 { store.increment_ip_count(&ip).await.unwrap(); } // The sustained map has seen nothing yet; it starts its own count. assert_eq!(store.increment_ip_count_sustained(&ip).await.unwrap(), 1); assert_eq!(store.increment_ip_count_sustained(&ip).await.unwrap(), 2); // And the minute counter was not touched by those increments. assert_eq!(store.increment_ip_count(&ip).await.unwrap(), 4); } #[tokio::test] async fn test_ip_rate_counter() { let store = InMemoryStore::new(); let ip: IpAddr = "127.0.0.1".parse().unwrap(); assert_eq!(store.increment_ip_count(&ip).await.unwrap(), 1); assert_eq!(store.increment_ip_count(&ip).await.unwrap(), 2); assert_eq!(store.increment_ip_count(&ip).await.unwrap(), 3); } #[tokio::test] async fn test_site_rate_counter() { let store = InMemoryStore::new(); let site_key = Uuid::new_v4(); assert_eq!(store.increment_site_count(&site_key).await.unwrap(), 1); assert_eq!(store.increment_site_count(&site_key).await.unwrap(), 2); } #[tokio::test] async fn test_behavior_blob_counter_is_per_site_and_per_fingerprint() { let store = InMemoryStore::new(); let site = Uuid::new_v4(); let other_site = Uuid::new_v4(); assert_eq!( store .increment_behavior_blob_count(&site, 42) .await .unwrap(), 1 ); assert_eq!( store .increment_behavior_blob_count(&site, 42) .await .unwrap(), 2 ); // A different blob on the same site is a separate count... assert_eq!( store .increment_behavior_blob_count(&site, 43) .await .unwrap(), 1 ); // ...and so is the same blob on a different site: one tenant's traffic // must never push another tenant's visitors over the threshold. assert_eq!( store .increment_behavior_blob_count(&other_site, 42) .await .unwrap(), 1 ); } #[tokio::test] async fn test_cleanup_reclaims_stale_behavior_blob_windows() { let store = InMemoryStore::new(); let site = Uuid::new_v4(); store.increment_behavior_blob_count(&site, 7).await.unwrap(); // Backdate past twice the dedup window, which is what `retain_fresh` // reclaims on. Without the sweep this map would grow for the process // lifetime. { let shard = store.behavior_blobs.shard(&(site, 7)); let mut map = shard.write().unwrap(); map.get_mut(&(site, 7)).unwrap().window_start = Utc::now().timestamp() - BEHAVIOR_DUPLICATE_WINDOW_SECS * 3; } store.cleanup_expired().await.unwrap(); // Assert on the map, not on a fresh count: `increment` rolls a stale // window over by itself, so a count of 1 would look identical whether // or not the sweeper ever ran. assert!( !store .behavior_blobs .shard(&(site, 7)) .read() .unwrap() .contains_key(&(site, 7)), "the sweeper must reclaim the dedup map, not just the rate windows" ); } #[tokio::test] async fn test_rotate_site_secret_in_memory() { let store = InMemoryStore::new(); let site = make_site(); store.store_site(&site).await.unwrap(); let new_secret = "rotated-secret".to_string(); store .rotate_site_secret(&site.site_key, new_secret.clone()) .await .unwrap(); // Old secret no longer resolves. assert!( store .get_site_by_secret(&site.secret_key) .await .unwrap() .is_none() ); // New secret resolves. let by_new = store .get_site_by_secret(&new_secret) .await .unwrap() .unwrap(); assert_eq!(by_new.site_key, site.site_key); assert_eq!(by_new.secret_key, new_secret); } #[tokio::test] async fn test_rotate_site_secret_not_found() { let store = InMemoryStore::new(); let result = store.rotate_site_secret(&Uuid::new_v4(), "x".into()).await; assert!(matches!(result, Err(CaptchaError::NotFound))); } #[tokio::test] async fn test_delete_site_in_memory() { let store = InMemoryStore::new(); let site = make_site(); store.store_site(&site).await.unwrap(); store.delete_site(&site.site_key).await.unwrap(); assert!( store .get_site_by_key(&site.site_key) .await .unwrap() .is_none() ); assert!( store .get_site_by_secret(&site.secret_key) .await .unwrap() .is_none() ); } #[tokio::test] async fn test_delete_site_not_found() { let store = InMemoryStore::new(); let result = store.delete_site(&Uuid::new_v4()).await; assert!(matches!(result, Err(CaptchaError::NotFound))); } #[tokio::test] async fn test_rotate_and_delete_persist_across_reopen() { let dir = tempdir(); let path = dir.join("sites.db"); let site_a = make_site(); let site_b = make_site(); { let store = InMemoryStore::with_site_persistence(&path).unwrap(); store.store_site(&site_a).await.unwrap(); store.store_site(&site_b).await.unwrap(); store .rotate_site_secret(&site_a.site_key, "rotated".into()) .await .unwrap(); store.delete_site(&site_b.site_key).await.unwrap(); } let store = InMemoryStore::with_site_persistence(&path).unwrap(); assert_eq!(store.site_count(), 1); let reloaded = store .get_site_by_secret("rotated") .await .unwrap() .expect("rotated secret resolves after reopen"); assert_eq!(reloaded.site_key, site_a.site_key); assert!( store .get_site_by_key(&site_b.site_key) .await .unwrap() .is_none(), "deleted site should not reload" ); } #[tokio::test] async fn test_site_persistence_round_trip() { let dir = tempdir(); let path = dir.join("sites.db"); // First store: register a site, drop the store. let site = make_site(); { let store = InMemoryStore::with_site_persistence(&path).unwrap(); store.store_site(&site).await.unwrap(); } // Second store: should reload the site from disk. let store = InMemoryStore::with_site_persistence(&path).unwrap(); assert_eq!(store.site_count(), 1); let by_key = store .get_site_by_key(&site.site_key) .await .unwrap() .expect("site reloaded by key"); assert_eq!(by_key.name, site.name); let by_secret = store .get_site_by_secret(&site.secret_key) .await .unwrap() .expect("site reloaded by secret"); assert_eq!(by_secret.site_key, site.site_key); } #[tokio::test] async fn test_allowed_origins_round_trip() { let dir = tempdir(); let path = dir.join("sites.db"); let mut site = make_site(); site.allowed_origins = vec![ "https://a.example".to_string(), "https://b.example:8443".to_string(), ]; { let store = InMemoryStore::with_site_persistence(&path).unwrap(); store.store_site(&site).await.unwrap(); } let store = InMemoryStore::with_site_persistence(&path).unwrap(); let reloaded = store .get_site_by_key(&site.site_key) .await .unwrap() .expect("site reloaded"); assert_eq!(reloaded.allowed_origins, site.allowed_origins); } #[tokio::test] async fn test_update_site_origins_persists_across_reopen() { let dir = tempdir(); let path = dir.join("sites.db"); let site = make_site(); { let store = InMemoryStore::with_site_persistence(&path).unwrap(); store.store_site(&site).await.unwrap(); // Site starts with no restriction; set one. store .update_site_origins(&site.site_key, vec!["https://only.example".to_string()]) .await .unwrap(); } let store = InMemoryStore::with_site_persistence(&path).unwrap(); let reloaded = store .get_site_by_key(&site.site_key) .await .unwrap() .expect("site reloaded"); assert_eq!(reloaded.allowed_origins, vec!["https://only.example"]); } #[tokio::test] async fn test_update_site_origins_not_found() { let store = InMemoryStore::new(); let result = store .update_site_origins(&Uuid::new_v4(), vec!["https://x.example".to_string()]) .await; assert!(matches!(result, Err(CaptchaError::NotFound))); } #[tokio::test] async fn test_update_site_name_persists_across_reopen() { let dir = tempdir(); let path = dir.join("sites.db"); let site = make_site(); { let store = InMemoryStore::with_site_persistence(&path).unwrap(); store.store_site(&site).await.unwrap(); store .update_site_name(&site.site_key, "renamed".to_string()) .await .unwrap(); } let store = InMemoryStore::with_site_persistence(&path).unwrap(); let reloaded = store .get_site_by_key(&site.site_key) .await .unwrap() .expect("site reloaded"); assert_eq!(reloaded.name, "renamed"); } #[tokio::test] async fn test_update_site_name_leaves_the_secret_lookup_intact() { // The name is not an index, but it shares the write path with the two // maps that are. A rename that disturbed `sites_by_secret` would break // every `/v1/verify` for that site with nothing in the response saying // why, so it gets a test rather than a comment. let store = InMemoryStore::new(); let site = make_site(); store.store_site(&site).await.unwrap(); store .update_site_name(&site.site_key, " renamed ".to_string()) .await .unwrap(); let found = store .get_site_by_secret(&site.secret_key) .await .unwrap() .expect("the secret still resolves"); assert_eq!(found.site_key, site.site_key); // Trimming is the handler's job (`normalize_site_name`), not the // store's — this pins that the store writes what it is handed. assert_eq!(found.name, " renamed "); } #[tokio::test] async fn test_update_site_name_not_found() { let store = InMemoryStore::new(); let result = store .update_site_name(&Uuid::new_v4(), "whatever".to_string()) .await; assert!(matches!(result, Err(CaptchaError::NotFound))); } #[tokio::test] async fn test_old_schema_db_migrates_allowed_origins_column() { let dir = tempdir(); let path = dir.join("sites.db"); // Hand-create the pre-feature table (no allowed_origins column) and // insert a row, exactly as an older binary would have left it. { let conn = Connection::open(&path).unwrap(); conn.execute_batch( r"CREATE TABLE sites ( site_key TEXT PRIMARY KEY, secret_key TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TEXT NOT NULL );", ) .unwrap(); conn.execute( "INSERT INTO sites (site_key, secret_key, name, created_at) VALUES (?1, ?2, ?3, ?4)", params![ Uuid::new_v4().to_string(), "legacy-secret", "Legacy Site", Utc::now().to_rfc3339(), ], ) .unwrap(); } // Opening with the current code must migrate the column in place and // load the legacy row with an empty (unrestricted) origin list. let store = InMemoryStore::with_site_persistence(&path).unwrap(); assert_eq!(store.site_count(), 1); let reloaded = store .get_site_by_secret("legacy-secret") .await .unwrap() .expect("legacy site reloaded"); assert!(reloaded.allowed_origins.is_empty()); // And the migrated column is usable going forward. store .update_site_origins(&reloaded.site_key, vec!["https://new.example".to_string()]) .await .unwrap(); // The same legacy row must also gain the `policy` column, and load // with no overrides — a site provisioned before policies existed keeps // tracking the global config exactly as it did. assert!(reloaded.policy.is_empty()); store .update_site_policy( &reloaded.site_key, SitePolicy { tier_block_min: Some(70), ..Default::default() }, ) .await .unwrap(); } #[tokio::test] async fn test_policy_round_trips_through_persistence() { let dir = tempdir(); let path = dir.join("sites.db"); let policy = SitePolicy { tier_checkbox_min: Some(10), tier_hard_pow_min: Some(25), tier_block_min: Some(60), ..Default::default() }; let site_key = { let store = InMemoryStore::with_site_persistence(&path).unwrap(); let mut site = make_site(); site.policy = policy; store.store_site(&site).await.unwrap(); site.site_key }; // Reopen: the policy must come back byte-identical, or a restart // silently reverts a tenant to the looser global thresholds. let store = InMemoryStore::with_site_persistence(&path).unwrap(); let reloaded = store.get_site_by_key(&site_key).await.unwrap().unwrap(); assert_eq!(reloaded.policy, policy); } #[tokio::test] async fn test_update_site_policy_persists_and_clears() { let dir = tempdir(); let path = dir.join("sites.db"); let site = make_site(); { let store = InMemoryStore::with_site_persistence(&path).unwrap(); store.store_site(&site).await.unwrap(); store .update_site_policy( &site.site_key, SitePolicy { verify_block_min: Some(30), ..Default::default() }, ) .await .unwrap(); } { let store = InMemoryStore::with_site_persistence(&path).unwrap(); let reloaded = store .get_site_by_key(&site.site_key) .await .unwrap() .unwrap(); assert_eq!(reloaded.policy.verify_block_min, Some(30)); // Clearing writes SQL NULL back, not "{}" — an emptied policy must // be indistinguishable from a site that never had one. store .update_site_policy(&site.site_key, SitePolicy::default()) .await .unwrap(); } let store = InMemoryStore::with_site_persistence(&path).unwrap(); let reloaded = store .get_site_by_key(&site.site_key) .await .unwrap() .unwrap(); assert!(reloaded.policy.is_empty()); let conn = Connection::open(&path).unwrap(); let stored: Option = conn .query_row( "SELECT policy FROM sites WHERE site_key = ?1", params![site.site_key.to_string()], |row| row.get(0), ) .unwrap(); assert_eq!(stored, None); } #[tokio::test] async fn test_update_site_policy_unknown_site_is_not_found() { let store = InMemoryStore::new(); let result = store .update_site_policy(&Uuid::new_v4(), SitePolicy::default()) .await; assert!(matches!(result, Err(CaptchaError::NotFound))); } #[tokio::test] async fn test_unparseable_policy_column_refuses_to_load() { // A corrupt policy must not degrade to "no overrides" — that would // silently restore the looser global thresholds for a site whose // operator deliberately tightened them. let dir = tempdir(); let path = dir.join("sites.db"); let site = make_site(); { let store = InMemoryStore::with_site_persistence(&path).unwrap(); store.store_site(&site).await.unwrap(); } let conn = Connection::open(&path).unwrap(); conn.execute( "UPDATE sites SET policy = ?1 WHERE site_key = ?2", params!["not json", site.site_key.to_string()], ) .unwrap(); drop(conn); assert!(InMemoryStore::with_site_persistence(&path).is_err()); } fn tempdir() -> std::path::PathBuf { let mut dir = std::env::temp_dir(); dir.push(format!( "bollwark-test-{}-{}", std::process::id(), Uuid::new_v4() )); std::fs::create_dir_all(&dir).unwrap(); dir } #[tokio::test] async fn test_cleanup_expired() { let store = InMemoryStore::new(); let expired = make_challenge(Uuid::new_v4(), -10); // already expired let valid = make_challenge(Uuid::new_v4(), 300); store.store_challenge(&expired).await.unwrap(); store.store_challenge(&valid).await.unwrap(); store.cleanup_expired().await.unwrap(); assert!(store.get_challenge(&expired.id).await.unwrap().is_none()); assert!(store.get_challenge(&valid.id).await.unwrap().is_some()); } }