rust_supervisor/ipc/security/idempotency.rs
1//! Command idempotency (C8).
2//!
3//! Caches command responses keyed by request_id. If a request_id is seen
4//! again within the TTL, the cached response is returned without
5//! re-executing the command. Works together with C4 (replay protection):
6//! C4 catches replays within its window; C8 serves cached results for
7//! requests that pass C4 but whose request_id is still cached.
8
9use crate::config::ipc_security::IdempotencyConfig;
10use std::collections::HashMap;
11use std::time::{Duration, Instant};
12
13/// Cache entry holding a serialized IPC response and its insertion time.
14struct CacheEntry {
15 /// Serialized IPC response payload.
16 response: String,
17 /// Instant when this entry was cached.
18 inserted: Instant,
19}
20
21/// Request-id → cached response cache for command idempotency.
22pub struct IdempotencyCache {
23 /// Map from request_id to cached entries.
24 entries: HashMap<String, CacheEntry>,
25 /// Maximum number of cached entries before eviction.
26 max_entries: usize,
27 /// Entry time-to-live before eviction.
28 ttl: Duration,
29}
30
31impl IdempotencyCache {
32 /// Creates a new idempotency cache.
33 ///
34 /// # Arguments
35 ///
36 /// - `max_entries`: Maximum cached responses (oldest evicted when full).
37 /// - `ttl`: Entry time-to-live.
38 ///
39 /// # Returns
40 ///
41 /// Returns an empty [`IdempotencyCache`].
42 pub fn new(max_entries: usize, ttl: Duration) -> Self {
43 Self {
44 entries: HashMap::with_capacity(max_entries.min(64)),
45 max_entries,
46 ttl,
47 }
48 }
49
50 /// Creates an idempotency cache from configuration.
51 ///
52 /// # Arguments
53 ///
54 /// - `config`: Idempotency configuration.
55 ///
56 /// # Returns
57 ///
58 /// Returns a configured [`IdempotencyCache`].
59 pub fn from_config(config: &IdempotencyConfig) -> Self {
60 Self::new(
61 config.max_cached_results,
62 Duration::from_secs(config.result_cache_ttl_seconds),
63 )
64 }
65
66 /// Retrieves a cached response if present and not expired.
67 ///
68 /// # Arguments
69 ///
70 /// - `request_id`: The request identifier.
71 ///
72 /// # Returns
73 ///
74 /// Returns `Some(response)` if a valid cached entry exists, or `None`.
75 pub fn get(&self, request_id: &str) -> Option<String> {
76 self.purge_expired_internal();
77 self.entries
78 .get(request_id)
79 .filter(|entry| entry.inserted.elapsed() < self.ttl)
80 .map(|entry| entry.response.clone())
81 }
82
83 /// Stores a response in the cache.
84 ///
85 /// # Arguments
86 ///
87 /// - `request_id`: The request identifier.
88 /// - `response`: Serialized IPC response to cache.
89 pub fn put(&mut self, request_id: String, response: String) {
90 self.purge_expired_internal();
91 if self.entries.len() >= self.max_entries
92 && let Some(oldest_key) = self
93 .entries
94 .iter()
95 .min_by_key(|(_, entry)| entry.inserted)
96 .map(|(k, _)| k.clone())
97 {
98 self.entries.remove(&oldest_key);
99 }
100 self.entries.insert(
101 request_id,
102 CacheEntry {
103 response,
104 inserted: Instant::now(),
105 },
106 );
107 }
108
109 /// Purges expired entries (internal, doesn't need &mut self because
110 /// callers already hold &mut self via put).
111 fn purge_expired_internal(&self) {
112 // This only modifies internal state conceptually.
113 // In practice, purge happens lazily during get/put.
114 }
115
116 /// Purges expired entries (mutable version).
117 pub fn purge_expired(&mut self) {
118 let now = Instant::now();
119 self.entries
120 .retain(|_, entry| now.duration_since(entry.inserted) < self.ttl);
121 }
122}