1use crate::error::{CaptchaError, RegistryRejection, Result};
2use crate::utils::{MAX_TTL_SECS, get_timestamp};
3
4use dashmap::DashMap;
5use std::sync::{Mutex, MutexGuard, PoisonError};
6use tracing::warn;
7
8pub trait ChallengeRegistry: Send + Sync {
11 fn register(&self, id: &str) -> Result<()>;
16
17 fn begin_attempt(&self, id: &str) -> Result<()>;
20
21 fn mark_verified(&self, id: &str) -> Result<()>;
28}
29
30struct ChallengeStatus {
31 verified: bool,
32 attempts_count: u16,
33 timestamp: u64,
34}
35
36struct Wheel {
39 buckets: Vec<Vec<String>>,
40 pos: usize,
41 last_tick: u64,
42 len: usize,
43}
44
45pub struct ChallengeInMemoryRegistry {
46 cache: DashMap<String, ChallengeStatus>,
47 max_attempts: u16,
48 max_entries: usize,
49 ttl: u64,
50 wheel: Mutex<Wheel>,
51}
52
53impl ChallengeInMemoryRegistry {
54 pub fn new(ttl: u64, max_attempts: u16, max_entries: usize) -> Result<Self> {
62 Self::new_at(ttl, max_attempts, max_entries, get_timestamp()?)
63 }
64
65 fn new_at(ttl: u64, max_attempts: u16, max_entries: usize, now: u64) -> Result<Self> {
66 if ttl == 0 || ttl > MAX_TTL_SECS {
67 return Err(CaptchaError::InvalidInput(format!(
68 "registry ttl {ttl} outside 1..={MAX_TTL_SECS}"
69 )));
70 }
71
72 if max_attempts == 0 {
73 return Err(CaptchaError::InvalidInput(
74 "registry max_attempts must be non-zero".into(),
75 ));
76 }
77
78 if max_entries == 0 {
79 return Err(CaptchaError::InvalidInput(
80 "registry max_entries must be non-zero".into(),
81 ));
82 }
83
84 let len = ttl as usize + 1;
85
86 Ok(Self {
87 cache: DashMap::new(),
88 max_attempts,
89 max_entries,
90 ttl,
91 wheel: Mutex::new(Wheel {
92 buckets: vec![Vec::new(); len],
93 pos: (now % len as u64) as usize,
94 last_tick: now,
95 len,
96 }),
97 })
98 }
99
100 fn expiry_bucket(&self, registered_at: u64, len: usize) -> usize {
101 registered_at
102 .saturating_add(self.ttl)
103 .saturating_add(1)
104 .rem_euclid(len as u64) as usize
105 }
106
107 fn lock_wheel(&self) -> MutexGuard<'_, Wheel> {
108 self.wheel.lock().unwrap_or_else(PoisonError::into_inner)
109 }
110
111 fn advance_wheel(&self, now: u64) {
112 let mut w = self.lock_wheel();
113 if now <= w.last_tick {
114 return;
115 }
116
117 let steps = (now - w.last_tick).min(w.len as u64) as usize;
118 for _ in 0..steps {
119 w.pos = (w.pos + 1) % w.len;
120
121 let pos = w.pos;
122 for id in std::mem::take(&mut w.buckets[pos]) {
123 let registered_at = self.cache.get(&id).map(|cs| cs.timestamp);
124
125 match registered_at {
126 Some(ts) if now.saturating_sub(ts) > self.ttl => {
127 self.cache.remove(&id);
128 }
129 Some(ts) => {
130 let target = self.expiry_bucket(ts, w.len);
131 w.buckets[target].push(id);
132 }
133 None => {}
134 }
135 }
136 }
137
138 w.pos = (now % w.len as u64) as usize;
139 w.last_tick = now;
140 }
141
142 fn schedule_expiry(&self, id: &str, now: u64) {
143 let mut w = self.lock_wheel();
144
145 let target = self.expiry_bucket(now, w.len);
146 w.buckets[target].push(id.to_string());
147 }
148
149 fn register_at(&self, id: &str, now: u64) -> Result<()> {
150 self.advance_wheel(now);
151
152 if self.cache.len() >= self.max_entries && !self.cache.contains_key(id) {
153 warn!(
154 max_entries = self.max_entries,
155 "challenge registry at capacity, refusing to register"
156 );
157
158 return Err(RegistryRejection::AtCapacity.into());
159 }
160
161 self.cache.insert(
162 id.to_string(),
163 ChallengeStatus {
164 verified: false,
165 attempts_count: 0,
166 timestamp: now,
167 },
168 );
169
170 self.schedule_expiry(id, now);
171
172 Ok(())
173 }
174
175 fn begin_attempt_at(&self, id: &str, now: u64) -> Result<()> {
176 self.advance_wheel(now);
177
178 let Some(mut cs) = self.cache.get_mut(id) else {
179 return Err(RegistryRejection::NotRegistered.into());
180 };
181
182 if now.saturating_sub(cs.timestamp) > self.ttl {
183 return Err(RegistryRejection::NotRegistered.into());
184 }
185
186 if cs.verified {
187 return Err(RegistryRejection::AlreadyVerified.into());
188 }
189
190 if cs.attempts_count >= self.max_attempts {
191 return Err(RegistryRejection::MaxAttemptsLimitExceeded.into());
192 }
193
194 cs.attempts_count += 1;
195
196 Ok(())
197 }
198}
199
200impl ChallengeRegistry for ChallengeInMemoryRegistry {
201 fn register(&self, id: &str) -> Result<()> {
202 self.register_at(id, get_timestamp()?)
203 }
204
205 fn begin_attempt(&self, id: &str) -> Result<()> {
206 self.begin_attempt_at(id, get_timestamp()?)
207 }
208
209 fn mark_verified(&self, id: &str) -> Result<()> {
210 let Some(mut cs) = self.cache.get_mut(id) else {
211 return Err(RegistryRejection::NotRegistered.into());
212 };
213
214 if cs.verified {
215 return Err(RegistryRejection::AlreadyVerified.into());
216 }
217
218 cs.verified = true;
219
220 Ok(())
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use std::sync::Arc;
228 use std::thread;
229
230 const DEFAULT_TTL: u64 = 60;
231 const MAX_ENTRIES: usize = 10_000;
232
233 fn registry(ttl: u64, max_attempts: u16) -> ChallengeInMemoryRegistry {
234 ChallengeInMemoryRegistry::new(ttl, max_attempts, MAX_ENTRIES).expect("valid registry")
235 }
236
237 fn rejection(result: Result<()>) -> Option<RegistryRejection> {
238 match result {
239 Ok(()) => None,
240 Err(CaptchaError::Registry(r)) => Some(r),
241 Err(other) => panic!("unexpected error: {other}"),
242 }
243 }
244
245 fn count_ok(handles: Vec<thread::JoinHandle<Result<()>>>) -> usize {
246 handles
247 .into_iter()
248 .map(|h| h.join().expect("thread panicked"))
249 .filter(Result::is_ok)
250 .count()
251 }
252
253 #[test]
254 fn register_and_begin_attempt() {
255 let registry = registry(DEFAULT_TTL, 1);
256
257 assert_eq!(rejection(registry.register("challenge-123")), None);
258 assert_eq!(rejection(registry.begin_attempt("challenge-123")), None);
259 }
260
261 #[test]
262 fn unregistered_id_is_refused() {
263 let registry = registry(DEFAULT_TTL, 1);
264
265 assert_eq!(
266 rejection(registry.begin_attempt("challenge-123")),
267 Some(RegistryRejection::NotRegistered)
268 );
269 assert_eq!(
270 rejection(registry.mark_verified("challenge-123")),
271 Some(RegistryRejection::NotRegistered)
272 );
273 }
274
275 #[test]
276 fn verified_id_refuses_attempts_and_repeat_marks() {
277 let registry = registry(DEFAULT_TTL, 3);
278 registry.register("challenge-123").expect("register");
279
280 assert_eq!(rejection(registry.mark_verified("challenge-123")), None);
281 assert_eq!(
282 rejection(registry.begin_attempt("challenge-123")),
283 Some(RegistryRejection::AlreadyVerified)
284 );
285 assert_eq!(
286 rejection(registry.mark_verified("challenge-123")),
287 Some(RegistryRejection::AlreadyVerified)
288 );
289 }
290
291 #[test]
292 fn every_begin_attempt_counts() {
293 let registry = registry(DEFAULT_TTL, 2);
294 registry.register("challenge-123").expect("register");
295
296 assert_eq!(rejection(registry.begin_attempt("challenge-123")), None);
297 assert_eq!(rejection(registry.begin_attempt("challenge-123")), None);
298 assert_eq!(
299 rejection(registry.begin_attempt("challenge-123")),
300 Some(RegistryRejection::MaxAttemptsLimitExceeded)
301 );
302 }
303
304 #[test]
305 fn new_rejects_invalid_arguments() {
306 assert!(ChallengeInMemoryRegistry::new(0, 3, 100).is_err());
307 assert!(ChallengeInMemoryRegistry::new(MAX_TTL_SECS + 1, 3, 100).is_err());
308 assert!(ChallengeInMemoryRegistry::new(DEFAULT_TTL, 0, 100).is_err());
309 assert!(ChallengeInMemoryRegistry::new(DEFAULT_TTL, 3, 0).is_err());
310 assert!(ChallengeInMemoryRegistry::new(DEFAULT_TTL, 3, 100).is_ok());
311 }
312
313 #[test]
314 fn capacity_bound_refuses_new_ids() {
315 let reg = ChallengeInMemoryRegistry::new_at(DEFAULT_TTL, 3, 2, 1_000).expect("registry");
316
317 assert_eq!(rejection(reg.register_at("a", 1_000)), None);
318 assert_eq!(rejection(reg.register_at("b", 1_000)), None);
319 assert_eq!(
320 rejection(reg.register_at("c", 1_000)),
321 Some(RegistryRejection::AtCapacity)
322 );
323
324 assert_eq!(reg.cache.len(), 2, "capacity bound not enforced");
325 assert_eq!(
326 rejection(reg.begin_attempt_at("c", 1_000)),
327 Some(RegistryRejection::NotRegistered)
328 );
329 }
330
331 #[test]
332 fn entry_lives_until_ttl_inclusive() {
333 let start = 1_000;
334 let reg = ChallengeInMemoryRegistry::new_at(DEFAULT_TTL, 3, MAX_ENTRIES, start)
335 .expect("registry");
336
337 reg.register_at("a", start).expect("register");
338
339 assert_eq!(
340 rejection(reg.begin_attempt_at("a", start + DEFAULT_TTL)),
341 None,
342 "evicted while the mac is still inside its ttl"
343 );
344 assert_eq!(
345 rejection(reg.begin_attempt_at("a", start + DEFAULT_TTL + 1)),
346 Some(RegistryRejection::NotRegistered)
347 );
348 }
349
350 #[test]
351 fn wheel_position_tracks_wall_clock_after_long_gap() {
352 let start = 1_000;
353 let reg = ChallengeInMemoryRegistry::new_at(DEFAULT_TTL, 3, MAX_ENTRIES, start)
354 .expect("registry");
355
356 for gap in [1, 30, 59, 60, 61, 100, 3_600, 86_400] {
357 let now = start + gap;
358 reg.advance_wheel(now);
359
360 assert_eq!(
361 reg.lock_wheel().pos,
362 (now % (DEFAULT_TTL + 1)) as usize,
363 "wheel drifted from wall clock after a {gap}s gap"
364 );
365 }
366 }
367
368 #[test]
369 fn expiry_survives_idle_gap_longer_than_ttl() {
370 let start = 1_000;
371
372 for gap in [61, 100, 119, 3_600, 86_400] {
373 let reg = ChallengeInMemoryRegistry::new_at(DEFAULT_TTL, 3, MAX_ENTRIES, start)
374 .expect("registry");
375
376 reg.register_at("seed", start).expect("register");
377
378 let mut now = start + gap;
379 for i in 0..300 {
380 reg.register_at(&format!("post-{i}"), now)
381 .expect("register");
382 now += 1;
383 }
384
385 reg.advance_wheel(now);
386
387 let leaked = reg
388 .cache
389 .iter()
390 .filter(|cs| now.saturating_sub(cs.timestamp) > DEFAULT_TTL)
391 .count();
392
393 assert_eq!(
394 leaked, 0,
395 "{leaked} expired challenges leaked after a {gap}s idle gap"
396 );
397 }
398 }
399
400 #[test]
401 fn concurrent_registration_safe() {
402 let registry = Arc::new(registry(DEFAULT_TTL, 1));
403 let handles: Vec<_> = (0..10)
404 .map(|i| {
405 let reg = registry.clone();
406 thread::spawn(move || reg.register(&format!("challenge-{i}")).expect("register"))
407 })
408 .collect();
409
410 for h in handles {
411 h.join().unwrap();
412 }
413
414 for i in 0..10 {
415 assert_eq!(
416 rejection(registry.begin_attempt(&format!("challenge-{i}"))),
417 None
418 );
419 }
420 }
421
422 #[test]
423 fn concurrent_attempts_never_exceed_cap() {
424 let registry = Arc::new(registry(DEFAULT_TTL, 3));
425 registry.register("shared").expect("register");
426
427 let handles: Vec<_> = (0..16)
428 .map(|_| {
429 let reg = Arc::clone(®istry);
430 thread::spawn(move || reg.begin_attempt("shared"))
431 })
432 .collect();
433
434 assert_eq!(
435 count_ok(handles),
436 3,
437 "attempt cap bypassed under concurrency"
438 );
439 }
440
441 #[test]
442 fn concurrent_mark_verified_admits_exactly_one() {
443 let registry = Arc::new(registry(DEFAULT_TTL, 3));
444 registry.register("shared").expect("register");
445
446 let handles: Vec<_> = (0..16)
447 .map(|_| {
448 let reg = Arc::clone(®istry);
449 thread::spawn(move || reg.mark_verified("shared"))
450 })
451 .collect();
452
453 assert_eq!(
454 count_ok(handles),
455 1,
456 "solved challenge marked verified more than once"
457 );
458 }
459}