Skip to main content

geronimo_captcha/
registry.rs

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
8/// Implementations must make `begin_attempt`
9/// and `mark_verified` atomic per id.
10pub trait ChallengeRegistry: Send + Sync {
11    /// # Errors
12    ///
13    /// Returns [`RegistryRejection::AtCapacity`] when the
14    /// registry is full. The caller must discard the challenge.
15    fn register(&self, id: &str) -> Result<()>;
16
17    /// Every `Ok` counts toward the attempt limit,
18    /// whether or not the guess is correct.
19    fn begin_attempt(&self, id: &str) -> Result<()>;
20
21    /// Exactly one caller succeeds per id.
22    ///
23    /// # Errors
24    ///
25    /// [`RegistryRejection::AlreadyVerified`] on a repeat,
26    /// [`RegistryRejection::NotRegistered`] on an unknown id.
27    fn mark_verified(&self, id: &str) -> Result<()>;
28}
29
30struct ChallengeStatus {
31    verified: bool,
32    attempts_count: u16,
33    timestamp: u64,
34}
35
36// Invariant: `len == ttl + 1` and `pos == now % len`.
37// `schedule_expiry` indexes buckets by wall clock.
38struct 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    /// `ttl` is in seconds. At `max_entries`
55    /// further ids are refused, not evicted.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`CaptchaError::InvalidInput`] if `ttl` is outside
60    /// 1..=86400, or if `max_attempts` or `max_entries` is zero.
61    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(&registry);
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(&registry);
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}