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}