use crate::fault::SoapFault;
use std::collections::HashSet;
use std::time::Instant;
const DEFAULT_MAX_ENTRIES: usize = 100_000;
pub struct RotatingNonceCache {
current: HashSet<String>,
previous: HashSet<String>,
bucket_start: Instant,
half_window_secs: u64,
max_entries: usize,
}
impl RotatingNonceCache {
pub fn new(half_window_secs: u64) -> Self {
Self::with_max_entries(half_window_secs, DEFAULT_MAX_ENTRIES)
}
pub fn with_max_entries(half_window_secs: u64, max_entries: usize) -> Self {
Self {
current: HashSet::new(),
previous: HashSet::new(),
bucket_start: Instant::now(),
half_window_secs,
max_entries,
}
}
pub fn check_and_insert(&mut self, nonce: &str) -> Result<(), SoapFault> {
self.rotate_if_needed();
if self.current.contains(nonce) || self.previous.contains(nonce) {
return Err(SoapFault::sender("WS-Security nonce replay detected"));
}
if self.current.len() >= self.max_entries {
self.previous = std::mem::take(&mut self.current);
self.bucket_start = Instant::now();
}
self.current.insert(nonce.to_string());
Ok(())
}
fn rotate_if_needed(&mut self) {
while self.bucket_start.elapsed().as_secs() >= self.half_window_secs {
self.previous = std::mem::take(&mut self.current);
self.bucket_start += std::time::Duration::from_secs(self.half_window_secs);
}
}
#[cfg(test)]
pub fn force_rotate(&mut self) {
self.previous = std::mem::take(&mut self.current);
self.bucket_start = Instant::now();
}
#[cfg(test)]
pub fn rewind_bucket_start(&mut self, secs: u64) {
self.bucket_start -= std::time::Duration::from_secs(secs);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_cache_accepts_new_nonce() {
let mut cache = RotatingNonceCache::new(150);
assert!(cache.check_and_insert("abc").is_ok());
}
#[test]
fn same_nonce_rejected_on_second_call() {
let mut cache = RotatingNonceCache::new(150);
cache.check_and_insert("abc").unwrap();
let result = cache.check_and_insert("abc");
assert!(result.is_err());
let fault = result.unwrap_err();
assert!(fault.reason.contains("replay"), "got: {}", fault.reason);
}
#[test]
fn different_nonce_accepted() {
let mut cache = RotatingNonceCache::new(150);
cache.check_and_insert("abc").unwrap();
assert!(cache.check_and_insert("xyz").is_ok());
}
#[test]
fn nonce_in_previous_bucket_still_detected_as_replay() {
let mut cache = RotatingNonceCache::new(150);
cache.check_and_insert("abc").unwrap();
cache.force_rotate();
let result = cache.check_and_insert("abc");
assert!(result.is_err(), "Expected replay error after rotation");
}
#[test]
fn nonce_dropped_after_two_rotations() {
let mut cache = RotatingNonceCache::new(150);
cache.check_and_insert("abc").unwrap();
cache.force_rotate();
cache.force_rotate();
assert!(
cache.check_and_insert("abc").is_ok(),
"Expected nonce to be accepted after two rotations"
);
}
#[test]
fn idle_gap_regression_nonce_accepted_after_two_window_gap() {
let half_window = 5u64;
let mut cache = RotatingNonceCache::new(half_window);
cache.check_and_insert("gap_nonce").unwrap();
cache.rewind_bucket_start(half_window * 2 + 1);
assert!(
cache.check_and_insert("gap_nonce").is_ok(),
"Nonce must be accepted after a 2× half_window idle gap (BLOCK-SS-C02 regression)"
);
}
#[test]
fn nonce_cache_enforces_max_entries_cap() {
let cap = 10usize;
let mut cache = RotatingNonceCache::with_max_entries(150, cap);
for i in 0..cap {
cache.check_and_insert(&format!("nonce_{i}")).unwrap();
}
cache.check_and_insert("nonce_overflow").unwrap();
let total = cache.current.len() + cache.previous.len();
assert!(
total <= cap + 1,
"Total live nonces ({total}) must not exceed cap+1 ({}) after overflow insert",
cap + 1
);
}
#[test]
fn nonce_cache_cap_does_not_reject_new_nonces_after_rotation() {
let cap = 5usize;
let mut cache = RotatingNonceCache::with_max_entries(150, cap);
for i in 0..cap {
cache.check_and_insert(&format!("n{i}")).unwrap();
}
let result = cache.check_and_insert("new_nonce_after_cap");
assert!(
result.is_ok(),
"New nonce must be accepted after forced rotation, got: {:?}",
result.err()
);
}
#[test]
fn nonce_in_overflow_bucket_still_rejected_as_replay() {
let cap = 3usize;
let mut cache = RotatingNonceCache::with_max_entries(150, cap);
cache.check_and_insert("target").unwrap();
cache.check_and_insert("n1").unwrap();
cache.check_and_insert("n2").unwrap();
cache.check_and_insert("n3").unwrap();
let result = cache.check_and_insert("target");
assert!(
result.is_err(),
"Nonce in previous bucket must still be detected as replay after forced rotation"
);
}
}