1use lru::LruCache;
4use std::num::NonZeroUsize;
5use std::time::Duration;
6
7pub struct ReplayGuard {
11 seen: LruCache<String, ()>,
12 window: Duration,
13}
14
15impl ReplayGuard {
16 pub fn new(capacity: usize, window_secs: u64) -> Self {
17 Self {
18 seen: LruCache::new(NonZeroUsize::new(capacity.max(1)).unwrap()),
19 window: Duration::from_secs(window_secs),
20 }
21 }
22
23 pub fn check(
25 &mut self,
26 message_id: &str,
27 nonce: Option<&str>,
28 timestamp: &str,
29 ) -> Result<(), String> {
30 if let Ok(msg_time) = chrono::DateTime::parse_from_rfc3339(timestamp) {
32 let now = chrono::Utc::now();
33 let diff = (now - msg_time.with_timezone(&chrono::Utc))
34 .num_seconds()
35 .unsigned_abs();
36 if diff > self.window.as_secs() {
37 return Err(format!(
38 "Message timestamp too old: {}s > {}s window",
39 diff,
40 self.window.as_secs()
41 ));
42 }
43 }
44
45 if let Some(nonce) = nonce {
47 let key = format!("{}:{}", message_id, nonce);
48 if self.seen.contains(&key) {
49 return Err("Duplicate message_id + nonce pair".into());
50 }
51 self.seen.put(key, ());
52 }
53
54 Ok(())
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 #[test]
63 fn accepts_fresh_message() {
64 let mut guard = ReplayGuard::new(100, 60);
65 let ts = chrono::Utc::now().to_rfc3339();
66 assert!(guard.check("m1", Some("nonce1"), &ts).is_ok());
67 }
68
69 #[test]
70 fn rejects_duplicate_nonce() {
71 let mut guard = ReplayGuard::new(100, 60);
72 let ts = chrono::Utc::now().to_rfc3339();
73 assert!(guard.check("m1", Some("nonce1"), &ts).is_ok());
74 assert!(guard.check("m1", Some("nonce1"), &ts).is_err());
75 }
76
77 #[test]
78 fn accepts_different_nonce_same_message_id() {
79 let mut guard = ReplayGuard::new(100, 60);
80 let ts = chrono::Utc::now().to_rfc3339();
81 assert!(guard.check("m1", Some("nonce1"), &ts).is_ok());
82 assert!(guard.check("m1", Some("nonce2"), &ts).is_ok());
83 }
84
85 #[test]
86 fn rejects_stale_timestamp() {
87 let mut guard = ReplayGuard::new(100, 60);
88 let old_ts = (chrono::Utc::now() - chrono::Duration::seconds(120)).to_rfc3339();
89 assert!(guard.check("m1", Some("nonce1"), &old_ts).is_err());
90 }
91
92 #[test]
93 fn skips_dedup_when_no_nonce() {
94 let mut guard = ReplayGuard::new(100, 60);
95 let ts = chrono::Utc::now().to_rfc3339();
96 assert!(guard.check("m1", None, &ts).is_ok());
97 assert!(guard.check("m1", None, &ts).is_ok());
98 }
99
100 #[test]
101 fn lru_evicts_oldest_at_capacity() {
102 let mut guard = ReplayGuard::new(2, 60);
103 let ts = chrono::Utc::now().to_rfc3339();
104 assert!(guard.check("m1", Some("n1"), &ts).is_ok());
105 assert!(guard.check("m2", Some("n2"), &ts).is_ok());
106 assert!(guard.check("m3", Some("n3"), &ts).is_ok());
107 assert!(guard.check("m1", Some("n1"), &ts).is_ok());
109 assert!(guard.check("m3", Some("n3"), &ts).is_err());
111 }
112}