#[cfg(not(feature = "alloc"))]
use heapless::String as HString;
#[cfg(feature = "alloc")]
use alloc::collections::VecDeque;
#[cfg(feature = "alloc")]
use alloc::string::String;
pub const PACKET_ID_CAP: usize = 64;
pub const TIMESTAMP_CAP: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RejectReason {
Replay,
Expired,
FutureDated,
IdTooLarge,
CacheFull,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReceiverDecision {
Accept,
Reject(RejectReason),
}
#[cfg(feature = "alloc")]
#[derive(Debug)]
pub struct PacketReceiver {
seen: VecDeque<(String, String)>,
capacity: usize,
}
#[cfg(feature = "alloc")]
impl PacketReceiver {
pub fn new(capacity: usize) -> Self {
let cap = capacity.max(1);
PacketReceiver {
seen: VecDeque::with_capacity(cap),
capacity: cap,
}
}
pub fn observe(
&mut self,
packet_id: &str,
expires_at: Option<&str>,
now: &str,
) -> ReceiverDecision {
if let Some(exp) = expires_at {
if exp < now {
return ReceiverDecision::Reject(RejectReason::Expired);
}
}
if self.seen.iter().any(|(id, _)| id == packet_id) {
return ReceiverDecision::Reject(RejectReason::Replay);
}
if self.seen.len() >= self.capacity {
self.seen.pop_front();
}
self.seen
.push_back((packet_id.into(), expires_at.unwrap_or("").into()));
ReceiverDecision::Accept
}
pub fn len(&self) -> usize {
self.seen.len()
}
pub fn is_empty(&self) -> bool {
self.seen.is_empty()
}
}
#[cfg(not(feature = "alloc"))]
#[derive(Debug)]
pub struct PacketReceiver<const N: usize> {
seen: heapless::Deque<HString<PACKET_ID_CAP>, N>,
}
#[cfg(not(feature = "alloc"))]
impl<const N: usize> PacketReceiver<N> {
pub fn new() -> Self {
PacketReceiver {
seen: heapless::Deque::new(),
}
}
pub fn observe(
&mut self,
packet_id: &str,
expires_at: Option<&str>,
now: &str,
) -> ReceiverDecision {
if let Some(exp) = expires_at {
if exp < now {
return ReceiverDecision::Reject(RejectReason::Expired);
}
}
let mut hid: HString<PACKET_ID_CAP> = HString::new();
if hid.push_str(packet_id).is_err() {
return ReceiverDecision::Reject(RejectReason::IdTooLarge);
}
if self.seen.iter().any(|s| s == &hid) {
return ReceiverDecision::Reject(RejectReason::Replay);
}
if self.seen.is_full() {
self.seen.pop_front();
}
if self.seen.push_back(hid).is_err() {
return ReceiverDecision::Reject(RejectReason::CacheFull);
}
ReceiverDecision::Accept
}
pub fn len(&self) -> usize {
self.seen.len()
}
pub fn is_empty(&self) -> bool {
self.seen.is_empty()
}
}
#[cfg(not(feature = "alloc"))]
impl<const N: usize> Default for PacketReceiver<N> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "alloc")]
#[test]
fn replay_rejected_alloc() {
let mut rx = PacketReceiver::new(8);
let now = "2026-04-25T00:00:00Z";
assert_eq!(
rx.observe("pkt-001", Some("2099-01-01T00:00:00Z"), now),
ReceiverDecision::Accept
);
assert_eq!(
rx.observe("pkt-001", Some("2099-01-01T00:00:00Z"), now),
ReceiverDecision::Reject(RejectReason::Replay)
);
assert_eq!(rx.len(), 1);
}
#[cfg(feature = "alloc")]
#[test]
fn expired_rejected_alloc() {
let mut rx = PacketReceiver::new(8);
assert_eq!(
rx.observe(
"pkt-1",
Some("2026-04-01T00:00:00Z"),
"2026-04-25T00:00:00Z"
),
ReceiverDecision::Reject(RejectReason::Expired)
);
}
#[cfg(feature = "alloc")]
#[test]
fn evicts_oldest_when_full_alloc() {
let mut rx = PacketReceiver::new(2);
rx.observe("a", None, "2026-04-25T00:00:00Z");
rx.observe("b", None, "2026-04-25T00:00:00Z");
rx.observe("c", None, "2026-04-25T00:00:00Z");
assert_eq!(
rx.observe("a", None, "2026-04-25T00:00:00Z"),
ReceiverDecision::Accept
);
}
#[cfg(not(feature = "alloc"))]
#[test]
fn replay_rejected_no_alloc() {
let mut rx: PacketReceiver<8> = PacketReceiver::new();
let now = "2026-04-25T00:00:00Z";
assert_eq!(
rx.observe("pkt-001", Some("2099-01-01T00:00:00Z"), now),
ReceiverDecision::Accept
);
assert_eq!(
rx.observe("pkt-001", Some("2099-01-01T00:00:00Z"), now),
ReceiverDecision::Reject(RejectReason::Replay)
);
}
}