Skip to main content

tf_core_no_std/
nonce_cache.rs

1//! Replay-protected packet receiver — no_std edition.
2//!
3//! Mirrors `tf-types::constrained::PacketReceiver`. The receiver keeps
4//! a sliding window of recently-seen `(packet_id, expires_at)` pairs:
5//!
6//! * On `observe`, if `expires_at < now`, return `Reject(Expired)`.
7//! * Otherwise, if `packet_id` is already in the window, return
8//!   `Reject(Replay)`.
9//! * Otherwise, accept and record. The window evicts FIFO once it
10//!   reaches its bounded capacity.
11//!
12//! With the `alloc` feature, the cache is backed by a `VecDeque` /
13//! `BTreeSet` pair sized at runtime (capacity is still bounded). With
14//! `--no-default-features`, the cache uses `heapless::Deque` and a
15//! membership probe over the deque, both with a const-generic `N`.
16
17#[cfg(not(feature = "alloc"))]
18use heapless::String as HString;
19
20#[cfg(feature = "alloc")]
21use alloc::collections::VecDeque;
22#[cfg(feature = "alloc")]
23use alloc::string::String;
24
25/// Maximum length of a packet ID (per TF-0001 actor-id sizing — packet
26/// IDs are short ULIDs / UUIDs but we leave headroom).
27pub const PACKET_ID_CAP: usize = 64;
28/// Maximum length of an `expires_at` ISO-8601 timestamp (`YYYY-MM-DDTHH:MM:SSZ`).
29pub const TIMESTAMP_CAP: usize = 32;
30
31/// Why a `PacketReceiver` rejected a packet.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum RejectReason {
34    Replay,
35    Expired,
36    FutureDated,
37    /// Packet ID exceeded the local cap.
38    IdTooLarge,
39    /// Cache full and no entry could be evicted.
40    CacheFull,
41}
42
43/// Result of `PacketReceiver::observe`.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum ReceiverDecision {
46    Accept,
47    Reject(RejectReason),
48}
49
50/* -------------------- alloc backing -------------------- */
51
52/// `PacketReceiver` with a `VecDeque`-backed window. Available with the
53/// `alloc` feature.
54#[cfg(feature = "alloc")]
55#[derive(Debug)]
56pub struct PacketReceiver {
57    seen: VecDeque<(String, String)>,
58    capacity: usize,
59}
60
61#[cfg(feature = "alloc")]
62impl PacketReceiver {
63    pub fn new(capacity: usize) -> Self {
64        let cap = capacity.max(1);
65        PacketReceiver {
66            seen: VecDeque::with_capacity(cap),
67            capacity: cap,
68        }
69    }
70
71    pub fn observe(
72        &mut self,
73        packet_id: &str,
74        expires_at: Option<&str>,
75        now: &str,
76    ) -> ReceiverDecision {
77        if let Some(exp) = expires_at {
78            if exp < now {
79                return ReceiverDecision::Reject(RejectReason::Expired);
80            }
81        }
82        if self.seen.iter().any(|(id, _)| id == packet_id) {
83            return ReceiverDecision::Reject(RejectReason::Replay);
84        }
85        if self.seen.len() >= self.capacity {
86            self.seen.pop_front();
87        }
88        self.seen
89            .push_back((packet_id.into(), expires_at.unwrap_or("").into()));
90        ReceiverDecision::Accept
91    }
92
93    pub fn len(&self) -> usize {
94        self.seen.len()
95    }
96
97    pub fn is_empty(&self) -> bool {
98        self.seen.is_empty()
99    }
100}
101
102/* ------------------- heapless backing ------------------- */
103
104/// `PacketReceiver` with a fixed-capacity heapless backing. The cache
105/// holds up to `N` packet IDs.
106#[cfg(not(feature = "alloc"))]
107#[derive(Debug)]
108pub struct PacketReceiver<const N: usize> {
109    seen: heapless::Deque<HString<PACKET_ID_CAP>, N>,
110}
111
112#[cfg(not(feature = "alloc"))]
113impl<const N: usize> PacketReceiver<N> {
114    pub fn new() -> Self {
115        PacketReceiver {
116            seen: heapless::Deque::new(),
117        }
118    }
119
120    pub fn observe(
121        &mut self,
122        packet_id: &str,
123        expires_at: Option<&str>,
124        now: &str,
125    ) -> ReceiverDecision {
126        if let Some(exp) = expires_at {
127            if exp < now {
128                return ReceiverDecision::Reject(RejectReason::Expired);
129            }
130        }
131        let mut hid: HString<PACKET_ID_CAP> = HString::new();
132        if hid.push_str(packet_id).is_err() {
133            return ReceiverDecision::Reject(RejectReason::IdTooLarge);
134        }
135        if self.seen.iter().any(|s| s == &hid) {
136            return ReceiverDecision::Reject(RejectReason::Replay);
137        }
138        if self.seen.is_full() {
139            self.seen.pop_front();
140        }
141        if self.seen.push_back(hid).is_err() {
142            return ReceiverDecision::Reject(RejectReason::CacheFull);
143        }
144        ReceiverDecision::Accept
145    }
146
147    pub fn len(&self) -> usize {
148        self.seen.len()
149    }
150
151    pub fn is_empty(&self) -> bool {
152        self.seen.is_empty()
153    }
154}
155
156#[cfg(not(feature = "alloc"))]
157impl<const N: usize> Default for PacketReceiver<N> {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[cfg(feature = "alloc")]
168    #[test]
169    fn replay_rejected_alloc() {
170        let mut rx = PacketReceiver::new(8);
171        let now = "2026-04-25T00:00:00Z";
172        assert_eq!(
173            rx.observe("pkt-001", Some("2099-01-01T00:00:00Z"), now),
174            ReceiverDecision::Accept
175        );
176        assert_eq!(
177            rx.observe("pkt-001", Some("2099-01-01T00:00:00Z"), now),
178            ReceiverDecision::Reject(RejectReason::Replay)
179        );
180        assert_eq!(rx.len(), 1);
181    }
182
183    #[cfg(feature = "alloc")]
184    #[test]
185    fn expired_rejected_alloc() {
186        let mut rx = PacketReceiver::new(8);
187        assert_eq!(
188            rx.observe(
189                "pkt-1",
190                Some("2026-04-01T00:00:00Z"),
191                "2026-04-25T00:00:00Z"
192            ),
193            ReceiverDecision::Reject(RejectReason::Expired)
194        );
195    }
196
197    #[cfg(feature = "alloc")]
198    #[test]
199    fn evicts_oldest_when_full_alloc() {
200        let mut rx = PacketReceiver::new(2);
201        rx.observe("a", None, "2026-04-25T00:00:00Z");
202        rx.observe("b", None, "2026-04-25T00:00:00Z");
203        rx.observe("c", None, "2026-04-25T00:00:00Z");
204        // 'a' was evicted, so re-submitting it must accept.
205        assert_eq!(
206            rx.observe("a", None, "2026-04-25T00:00:00Z"),
207            ReceiverDecision::Accept
208        );
209    }
210
211    #[cfg(not(feature = "alloc"))]
212    #[test]
213    fn replay_rejected_no_alloc() {
214        let mut rx: PacketReceiver<8> = PacketReceiver::new();
215        let now = "2026-04-25T00:00:00Z";
216        assert_eq!(
217            rx.observe("pkt-001", Some("2099-01-01T00:00:00Z"), now),
218            ReceiverDecision::Accept
219        );
220        assert_eq!(
221            rx.observe("pkt-001", Some("2099-01-01T00:00:00Z"), now),
222            ReceiverDecision::Reject(RejectReason::Replay)
223        );
224    }
225}