Skip to main content

attested_request/
replay.rs

1//! Optional server-side replay tracking.
2//!
3//! `created` already bounds how long a captured request stays valid. A replay guard additionally
4//! refuses a second use of the same request inside that window, at the cost of a store on the
5//! request path. Whether that is worth it depends on the route: a request whose effect can be
6//! repeated needs one, a request that only opens a session usually does not.
7
8use std::{
9    collections::HashMap,
10    error::Error,
11    sync::Mutex,
12    time::{Duration, Instant},
13};
14
15use crate::token::BoxFuture;
16
17/// Records verified requests so that each is accepted once.
18pub trait ReplayGuard: Send + Sync {
19    /// Claims `binding` for `ttl`: `Ok(true)` the first time, `Ok(false)` when already claimed.
20    ///
21    /// The claim must be atomic across every verifier that shares the store. Unexpired claims
22    /// must not be evicted: if the store cannot retain a new claim, return an error.
23    /// Store errors fail verification closed and are preserved as the rejection source.
24    fn claim<'a>(
25        &'a self,
26        binding: &'a str,
27        ttl: Duration,
28    ) -> BoxFuture<'a, Result<bool, Box<dyn Error + Send + Sync>>>;
29}
30
31/// A process-local replay guard for tests and low-traffic, single-replica services.
32///
33/// Claims are not shared between processes, so this only protects a single replica. Use a shared
34/// store when the service runs more than one. Each claim scans the entire map under a mutex
35/// to remove expired entries, so use a shared store with native expiry for production traffic.
36#[derive(Debug, Default)]
37pub struct InMemoryReplayGuard {
38    claims: Mutex<HashMap<String, Instant>>,
39}
40
41impl ReplayGuard for InMemoryReplayGuard {
42    fn claim<'a>(
43        &'a self,
44        binding: &'a str,
45        ttl: Duration,
46    ) -> BoxFuture<'a, Result<bool, Box<dyn Error + Send + Sync>>> {
47        Box::pin(async move {
48            let now = Instant::now();
49            let mut claims = self
50                .claims
51                .lock()
52                .unwrap_or_else(std::sync::PoisonError::into_inner);
53            claims.retain(|_, expires_at| *expires_at > now);
54            if claims.contains_key(binding) {
55                return Ok(false);
56            }
57            claims.insert(binding.to_owned(), now + ttl);
58            Ok(true)
59        })
60    }
61}