use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::RwLock;
use std::time::{Duration, Instant};
use bytes::Bytes;
use crate::error::{Error, Result};
use crate::v3::UsmSecurityParams;
pub const TIME_WINDOW: u32 = 150;
pub const MAX_ENGINE_TIME: u32 = 2_147_483_647;
pub const DEFAULT_MSG_MAX_SIZE: u32 = 65507;
#[must_use]
pub fn compute_engine_boots_time(boots_base: u32, total_elapsed_secs: u64) -> (u32, u32) {
let cycle = u64::from(MAX_ENGINE_TIME) + 1;
let additional_boots = total_elapsed_secs / cycle;
let current_time = (total_elapsed_secs % cycle) as u32;
let boots = (u64::from(boots_base) + additional_boots).min(u64::from(MAX_ENGINE_TIME)) as u32;
(boots, current_time)
}
pub const MIN_ENGINE_ID_LEN: usize = 5;
pub const MAX_ENGINE_ID_LEN: usize = 32;
const GENERATED_ENGINE_ID_PEN: u32 = 32473;
const ENGINE_ID_FORMAT_OCTETS: u8 = 5;
const GENERATED_ENGINE_ID_RANDOM_LEN: usize = 12;
#[must_use]
pub fn generate_engine_id() -> Bytes {
let mut id = Vec::with_capacity(5 + GENERATED_ENGINE_ID_RANDOM_LEN);
let enterprise = 0x8000_0000_u32 | GENERATED_ENGINE_ID_PEN;
id.extend_from_slice(&enterprise.to_be_bytes());
id.push(ENGINE_ID_FORMAT_OCTETS);
let mut random = [0_u8; GENERATED_ENGINE_ID_RANDOM_LEN];
getrandom::fill(&mut random).expect("getrandom failed");
id.extend_from_slice(&random);
Bytes::from(id)
}
pub fn validate_engine_id(engine_id: &[u8]) -> Result<()> {
let len = engine_id.len();
if !(MIN_ENGINE_ID_LEN..=MAX_ENGINE_ID_LEN).contains(&len) {
return Err(Error::Config(
format!(
"engine ID length {len} out of range (must be {MIN_ENGINE_ID_LEN}..={MAX_ENGINE_ID_LEN} octets)"
)
.into(),
)
.boxed());
}
if engine_id.iter().all(|&b| b == 0x00) {
return Err(Error::Config("engine ID must not be all zero".into()).boxed());
}
if engine_id.iter().all(|&b| b == 0xff) {
return Err(Error::Config("engine ID must not be all 0xff".into()).boxed());
}
Ok(())
}
pub mod report_oids {
use crate::Oid;
use crate::oid;
#[must_use]
pub fn unsupported_sec_levels() -> Oid {
oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 1, 0)
}
#[must_use]
pub fn not_in_time_windows() -> Oid {
oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 2, 0)
}
#[must_use]
pub fn unknown_user_names() -> Oid {
oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 3, 0)
}
#[must_use]
pub fn unknown_engine_ids() -> Oid {
oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 4, 0)
}
#[must_use]
pub fn wrong_digests() -> Oid {
oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 5, 0)
}
#[must_use]
pub fn decryption_errors() -> Oid {
oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 6, 0)
}
}
#[derive(Debug, Clone)]
pub struct TrustedEngineTime {
boots: u32,
received_time_base: u32,
received_at: Instant,
latest_received_time: u32,
}
impl TrustedEngineTime {
fn new_at(boots: u32, time: u32, now: Instant) -> Self {
Self {
boots,
received_time_base: time,
received_at: now,
latest_received_time: time,
}
}
#[must_use]
pub fn boots(&self) -> u32 {
self.boots
}
#[must_use]
pub fn received_time_base(&self) -> u32 {
self.received_time_base
}
#[must_use]
pub fn latest_received_time(&self) -> u32 {
self.latest_received_time
}
fn estimated_at(&self, now: Instant) -> (u32, u32) {
if self.boots == MAX_ENGINE_TIME {
return (
MAX_ENGINE_TIME,
self.received_time_base.min(MAX_ENGINE_TIME),
);
}
let elapsed = now
.checked_duration_since(self.received_at)
.unwrap_or_default()
.as_secs();
let total_time = u64::from(self.received_time_base).saturating_add(elapsed);
let cycle = u64::from(MAX_ENGINE_TIME) + 1;
let additional_boots = total_time / cycle;
let engine_time = (total_time % cycle) as u32;
let engine_boots =
(u64::from(self.boots) + additional_boots).min(u64::from(MAX_ENGINE_TIME)) as u32;
(engine_boots, engine_time)
}
fn roll_forward_at(&mut self, now: Instant) {
let (estimated_boots, estimated_time) = self.estimated_at(now);
if estimated_boots > self.boots {
self.boots = estimated_boots;
self.received_time_base = estimated_time;
self.received_at = now;
self.latest_received_time = estimated_time;
}
}
fn update_at(&mut self, response_boots: u32, response_time: u32, now: Instant) -> bool {
self.roll_forward_at(now);
if response_boots > self.boots
|| (response_boots == self.boots && response_time > self.latest_received_time)
{
self.boots = response_boots;
self.received_time_base = response_time;
self.received_at = now;
self.latest_received_time = response_time;
true
} else {
false
}
}
}
#[derive(Debug, Clone)]
pub struct EngineState {
pub(crate) engine_id: Bytes,
pub msg_max_size: u32,
trusted_time: Option<TrustedEngineTime>,
}
impl EngineState {
pub fn new(engine_id: Bytes, engine_boots: u32, engine_time: u32) -> Self {
Self::with_msg_max_size(engine_id, engine_boots, engine_time, DEFAULT_MSG_MAX_SIZE)
}
#[must_use]
pub fn discovered(engine_id: Bytes, msg_max_size: u32) -> Self {
Self {
engine_id,
msg_max_size,
trusted_time: None,
}
}
pub fn with_msg_max_size(
engine_id: Bytes,
engine_boots: u32,
engine_time: u32,
msg_max_size: u32,
) -> Self {
Self {
engine_id,
msg_max_size,
trusted_time: Some(TrustedEngineTime::new_at(
engine_boots,
engine_time,
Instant::now(),
)),
}
}
pub fn with_msg_max_size_capped(
engine_id: Bytes,
engine_boots: u32,
engine_time: u32,
reported_msg_max_size: u32,
session_max: u32,
) -> Self {
Self::with_msg_max_size(
engine_id,
engine_boots,
engine_time,
cap_msg_max_size(reported_msg_max_size, session_max),
)
}
#[must_use]
pub fn engine_id(&self) -> &Bytes {
&self.engine_id
}
#[must_use]
pub fn trusted_time(&self) -> Option<&TrustedEngineTime> {
self.trusted_time.as_ref()
}
#[must_use]
pub fn estimated_boots_time(&self) -> (u32, u32) {
self.estimated_boots_time_at(Instant::now())
}
pub(crate) fn estimated_boots_time_at(&self, now: Instant) -> (u32, u32) {
self.trusted_time
.as_ref()
.map_or((0, 0), |time| time.estimated_at(now))
}
pub(crate) fn last_trusted_update_at(&self) -> Option<Instant> {
self.trusted_time.as_ref().map(|time| time.received_at)
}
#[must_use]
pub fn estimated_time(&self) -> u32 {
self.estimated_boots_time().1
}
pub fn update_time(&mut self, response_boots: u32, response_time: u32) -> bool {
self.update_time_at(response_boots, response_time, Instant::now())
}
fn update_time_at(&mut self, response_boots: u32, response_time: u32, now: Instant) -> bool {
match self.trusted_time.as_mut() {
Some(time) => time.update_at(response_boots, response_time, now),
None => {
self.trusted_time = Some(TrustedEngineTime::new_at(
response_boots,
response_time,
now,
));
true
}
}
}
pub(crate) fn merge_from(&mut self, other: &Self) -> bool {
if self.engine_id != other.engine_id {
return false;
}
self.msg_max_size = self.msg_max_size.min(other.msg_max_size);
let Some(other_time) = &other.trusted_time else {
return false;
};
match self.trusted_time.as_mut() {
Some(time) => time.update_at(
other_time.boots,
other_time.latest_received_time,
other_time.received_at,
),
None => {
self.trusted_time = Some(other_time.clone());
true
}
}
}
pub fn check_and_update_timeliness(&mut self, msg_boots: u32, msg_time: u32) -> bool {
self.check_and_update_timeliness_at(msg_boots, msg_time, Instant::now())
}
fn check_and_update_timeliness_at(
&mut self,
msg_boots: u32,
msg_time: u32,
now: Instant,
) -> bool {
self.update_time_at(msg_boots, msg_time, now);
let (local_boots, local_time) = self.estimated_boots_time_at(now);
local_boots != MAX_ENGINE_TIME
&& msg_boots >= local_boots
&& (msg_boots != local_boots || msg_time >= local_time.saturating_sub(TIME_WINDOW))
}
#[must_use]
pub fn is_in_time_window(&self, msg_boots: u32, msg_time: u32) -> bool {
let (local_boots, local_time) = self.estimated_boots_time();
in_authoritative_time_window(local_boots, local_time, msg_boots, msg_time)
}
}
fn cap_msg_max_size(reported: u32, session_max: u32) -> u32 {
if reported > session_max {
tracing::debug!(target: "async_snmp::v3", { reported, session_max }, "capping msgMaxSize to session limit");
session_max
} else {
reported
}
}
pub fn in_authoritative_time_window(
local_boots: u32,
local_time: u32,
msg_boots: u32,
msg_time: u32,
) -> bool {
local_boots != MAX_ENGINE_TIME
&& msg_boots == local_boots
&& msg_time.abs_diff(local_time) <= TIME_WINDOW
}
const DEFAULT_ENGINE_CACHE_TTL: Duration = Duration::from_secs(300);
#[derive(Debug)]
struct CachedTarget {
engine_id: Bytes,
msg_max_size: u32,
refreshed_at: Instant,
}
#[derive(Debug, Default)]
struct EngineCacheInner {
targets: HashMap<SocketAddr, CachedTarget>,
trusted_times: HashMap<Bytes, TrustedEngineTime>,
}
#[derive(Debug)]
pub struct EngineCache {
inner: RwLock<EngineCacheInner>,
max_capacity: Option<usize>,
ttl: Duration,
}
impl Default for EngineCache {
fn default() -> Self {
Self::new()
}
}
impl EngineCache {
#[must_use]
pub fn new() -> Self {
Self {
inner: RwLock::new(EngineCacheInner::default()),
max_capacity: None,
ttl: DEFAULT_ENGINE_CACHE_TTL,
}
}
#[must_use]
pub fn with_max_capacity(mut self, max_capacity: usize) -> Self {
self.max_capacity = Some(max_capacity.max(1));
self
}
#[must_use]
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
pub fn get(&self, target: &SocketAddr) -> Option<EngineState> {
self.get_at(target, Instant::now())
}
fn get_at(&self, target: &SocketAddr, now: Instant) -> Option<EngineState> {
let mut inner = self.inner.write().ok()?;
let cached = inner.targets.get(target)?;
if now
.checked_duration_since(cached.refreshed_at)
.unwrap_or_default()
> self.ttl
{
let engine_id = cached.engine_id.clone();
inner.targets.remove(target);
remove_orphaned_time(&mut inner, &engine_id);
return None;
}
compose_cached_state(&inner, target)
}
pub fn insert(&self, target: SocketAddr, state: EngineState) {
self.insert_at(target, state, Instant::now());
}
fn insert_at(&self, target: SocketAddr, state: EngineState, now: Instant) {
let _ = self.store_at(target, state, now, false);
}
pub(crate) fn replace_target(
&self,
target: SocketAddr,
state: EngineState,
) -> Result<EngineState> {
self.store_at(target, state, Instant::now(), true)
.ok_or_else(|| Error::Config("engine cache lock poisoned".into()).boxed())
}
fn store_at(
&self,
target: SocketAddr,
state: EngineState,
now: Instant,
replace_identity: bool,
) -> Option<EngineState> {
let mut inner = self.inner.write().ok()?;
if !replace_identity
&& let Some(existing) = inner.targets.get(&target)
&& existing.engine_id != state.engine_id
&& now
.checked_duration_since(existing.refreshed_at)
.unwrap_or_default()
<= self.ttl
{
return compose_cached_state(&inner, &target);
}
if let Some(cap) = self.max_capacity
&& !inner.targets.contains_key(&target)
&& inner.targets.len() >= cap
&& let Some((oldest_target, oldest_engine)) = inner
.targets
.iter()
.min_by_key(|(_, cached)| cached.refreshed_at)
.map(|(target, cached)| (*target, cached.engine_id.clone()))
{
inner.targets.remove(&oldest_target);
remove_orphaned_time(&mut inner, &oldest_engine);
}
let replaced_engine = inner
.targets
.get(&target)
.filter(|cached| cached.engine_id != state.engine_id)
.map(|cached| cached.engine_id.clone());
if let Some(trusted) = &state.trusted_time {
merge_trusted_time(&mut inner.trusted_times, &state.engine_id, trusted);
}
inner.targets.insert(
target,
CachedTarget {
engine_id: state.engine_id,
msg_max_size: state.msg_max_size,
refreshed_at: now,
},
);
if let Some(replaced_engine) = replaced_engine {
remove_orphaned_time(&mut inner, &replaced_engine);
}
compose_cached_state(&inner, &target)
}
pub fn update_time(
&self,
target: &SocketAddr,
response_boots: u32,
response_time: u32,
) -> bool {
self.update_time_at(target, response_boots, response_time, Instant::now())
}
fn update_time_at(
&self,
target: &SocketAddr,
response_boots: u32,
response_time: u32,
now: Instant,
) -> bool {
let Ok(mut inner) = self.inner.write() else {
return false;
};
let Some(engine_id) = inner
.targets
.get(target)
.map(|cached| cached.engine_id.clone())
else {
return false;
};
let changed = match inner.trusted_times.get_mut(&engine_id) {
Some(time) => time.update_at(response_boots, response_time, now),
None => {
inner.trusted_times.insert(
engine_id,
TrustedEngineTime::new_at(response_boots, response_time, now),
);
true
}
};
if let Some(cached) = inner.targets.get_mut(target) {
cached.refreshed_at = now;
}
changed
}
pub(crate) fn check_and_update_timeliness(
&self,
target: &SocketAddr,
local_state: &EngineState,
engine_id: &[u8],
msg_boots: u32,
msg_time: u32,
) -> Option<(bool, EngineState)> {
self.check_and_update_timeliness_at(
target,
local_state,
engine_id,
msg_boots,
msg_time,
Instant::now(),
)
}
pub(crate) fn timeliness_candidate(
&self,
target: &SocketAddr,
local_state: &EngineState,
engine_id: &[u8],
msg_boots: u32,
msg_time: u32,
) -> Option<(bool, EngineState)> {
let inner = self.inner.read().ok()?;
let cached_engine_id = &inner.targets.get(target)?.engine_id;
if cached_engine_id.as_ref() != engine_id || local_state.engine_id.as_ref() != engine_id {
return None;
}
let mut candidate = local_state.clone();
candidate.merge_from(&compose_cached_state(&inner, target)?);
let timely = candidate.check_and_update_timeliness(msg_boots, msg_time);
Some((timely, candidate))
}
fn check_and_update_timeliness_at(
&self,
target: &SocketAddr,
local_state: &EngineState,
engine_id: &[u8],
msg_boots: u32,
msg_time: u32,
now: Instant,
) -> Option<(bool, EngineState)> {
let mut inner = self.inner.write().ok()?;
let cached_engine_id = inner.targets.get(target)?.engine_id.clone();
if cached_engine_id.as_ref() != engine_id || local_state.engine_id.as_ref() != engine_id {
return None;
}
if let Some(local_time) = &local_state.trusted_time {
merge_trusted_time(&mut inner.trusted_times, &cached_engine_id, local_time);
}
let time = inner
.trusted_times
.entry(cached_engine_id.clone())
.or_insert_with(|| TrustedEngineTime::new_at(msg_boots, msg_time, now));
time.update_at(msg_boots, msg_time, now);
let (local_boots, local_time) = time.estimated_at(now);
let timely = local_boots != MAX_ENGINE_TIME
&& msg_boots >= local_boots
&& (msg_boots != local_boots || msg_time >= local_time.saturating_sub(TIME_WINDOW));
if timely {
inner.targets.get_mut(target)?.refreshed_at = now;
}
let state = compose_cached_state(&inner, target)?;
Some((timely, state))
}
pub fn remove(&self, target: &SocketAddr) -> Option<EngineState> {
let mut inner = self.inner.write().ok()?;
let state = compose_cached_state(&inner, target)?;
let cached = inner.targets.remove(target)?;
remove_orphaned_time(&mut inner, &cached.engine_id);
Some(state)
}
pub fn clear(&self) {
if let Ok(mut inner) = self.inner.write() {
inner.targets.clear();
inner.trusted_times.clear();
}
}
pub fn len(&self) -> usize {
self.inner.read().map_or(0, |inner| inner.targets.len())
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
fn compose_cached_state(inner: &EngineCacheInner, target: &SocketAddr) -> Option<EngineState> {
let cached = inner.targets.get(target)?;
Some(EngineState {
engine_id: cached.engine_id.clone(),
msg_max_size: cached.msg_max_size,
trusted_time: inner.trusted_times.get(&cached.engine_id).cloned(),
})
}
fn merge_trusted_time(
trusted_times: &mut HashMap<Bytes, TrustedEngineTime>,
engine_id: &Bytes,
incoming: &TrustedEngineTime,
) {
match trusted_times.get_mut(engine_id) {
Some(current) => {
current.update_at(
incoming.boots,
incoming.latest_received_time,
incoming.received_at,
);
}
None => {
trusted_times.insert(engine_id.clone(), incoming.clone());
}
}
}
fn remove_orphaned_time(inner: &mut EngineCacheInner, engine_id: &Bytes) {
if !inner
.targets
.values()
.any(|cached| cached.engine_id == engine_id)
{
inner.trusted_times.remove(engine_id);
}
}
pub fn parse_discovery_response(security_params: &Bytes) -> Result<EngineState> {
parse_discovery_response_with_limits(
security_params,
DEFAULT_MSG_MAX_SIZE,
DEFAULT_MSG_MAX_SIZE,
)
}
pub fn parse_discovery_response_with_limits(
security_params: &Bytes,
reported_msg_max_size: u32,
session_max: u32,
) -> Result<EngineState> {
let usm = UsmSecurityParams::decode(security_params.clone())?;
if validate_engine_id(&usm.engine_id).is_err() {
tracing::debug!(target: "async_snmp::engine", { length = usm.engine_id.len() }, "discovery response contained invalid engine ID");
return Err(Error::MalformedResponse {
target: SocketAddr::from(([0, 0, 0, 0], 0)),
}
.boxed());
}
Ok(EngineState::discovered(
usm.engine_id,
cap_msg_max_size(reported_msg_max_size, session_max),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_engine_id_is_valid_and_well_formed() {
let id = generate_engine_id();
assert!((MIN_ENGINE_ID_LEN..=MAX_ENGINE_ID_LEN).contains(&id.len()));
validate_engine_id(&id).expect("generated engine ID must validate");
assert_eq!(id[0] & 0x80, 0x80);
let enterprise = u32::from_be_bytes([id[0], id[1], id[2], id[3]]);
assert_eq!(enterprise, 0x8000_0000 | GENERATED_ENGINE_ID_PEN);
assert_eq!(id[4], ENGINE_ID_FORMAT_OCTETS);
assert_eq!(id.len(), 5 + GENERATED_ENGINE_ID_RANDOM_LEN);
}
#[test]
fn test_generate_engine_id_distinct_across_generations() {
let a = generate_engine_id();
let b = generate_engine_id();
assert_ne!(a, b, "two generated engine IDs must not collide");
}
#[test]
fn test_validate_engine_id_rejects_invalid() {
assert!(validate_engine_id(&[0x80, 0x00, 0x00, 0x01]).is_err());
assert!(validate_engine_id(&[0x11; MAX_ENGINE_ID_LEN + 1]).is_err());
assert!(validate_engine_id(&[0x00; 8]).is_err());
assert!(validate_engine_id(&[0xff; 8]).is_err());
}
#[test]
fn test_validate_engine_id_accepts_valid() {
validate_engine_id(&[0x80, 0x00, 0x00, 0x00, 0x01]).unwrap();
validate_engine_id(&[0x22; MAX_ENGINE_ID_LEN]).unwrap();
validate_engine_id(b"my-engine").unwrap();
}
#[test]
fn test_engine_state_estimated_time() {
let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
let estimated = state.estimated_time();
assert!(estimated >= 1000);
}
#[test]
fn test_engine_state_update_time() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
assert!(state.update_time(1, 1100));
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 1100);
assert!(!state.update_time(1, 1050));
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 1100);
assert!(state.update_time(2, 500));
assert_eq!(state.trusted_time().unwrap().boots(), 2);
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 500);
}
#[test]
fn test_anti_replay_rejects_old_time() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
assert!(state.update_time(1, 1500));
assert!(
!state.update_time(1, 1400),
"Should reject replay: time 1400 < latest 1500"
);
assert_eq!(
state.trusted_time().unwrap().latest_received_time(),
1500,
"Latest should not change"
);
assert!(
!state.update_time(1, 1500),
"Should reject replay: time 1500 == latest 1500"
);
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 1500);
assert!(
state.update_time(1, 1501),
"Should accept: time 1501 > latest 1500"
);
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 1501);
}
#[test]
fn test_anti_replay_new_boot_cycle_resets() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
assert!(state.update_time(1, 5000));
assert!(
state.update_time(2, 100),
"New boot cycle should accept even with lower time"
);
assert_eq!(state.trusted_time().unwrap().boots(), 2);
assert_eq!(state.trusted_time().unwrap().received_time_base(), 100);
assert_eq!(
state.trusted_time().unwrap().latest_received_time(),
100,
"Latest should reset to new time"
);
assert!(
!state.update_time(2, 50),
"Should reject older time in same boot cycle"
);
assert!(state.update_time(2, 150), "Should accept newer time");
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 150);
}
#[test]
fn test_anti_replay_rejects_old_boot_cycle() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 5, 1000);
assert!(
!state.update_time(4, 9999),
"Should reject old boot cycle even with high time"
);
assert_eq!(
state.trusted_time().unwrap().boots(),
5,
"Boots should not change"
);
assert_eq!(
state.trusted_time().unwrap().latest_received_time(),
1000,
"Latest should not change"
);
assert!(!state.update_time(0, 9999), "Should reject boots=0 replay");
}
#[test]
fn test_anti_replay_boundary_values() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 0);
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 0);
assert!(state.update_time(1, 1));
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 1);
assert!(!state.update_time(1, 0));
assert!(state.update_time(1, MAX_ENGINE_TIME - 1));
assert_eq!(
state.trusted_time().unwrap().latest_received_time(),
MAX_ENGINE_TIME - 1
);
assert!(state.update_time(1, MAX_ENGINE_TIME));
assert_eq!(state.estimated_boots_time(), (1, MAX_ENGINE_TIME));
assert!(!state.update_time(1, MAX_ENGINE_TIME));
}
#[test]
fn test_engine_state_time_window() {
let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
assert!(state.is_in_time_window(1, 1000));
assert!(state.is_in_time_window(1, 1100)); assert!(state.is_in_time_window(1, 900));
assert!(!state.is_in_time_window(2, 1000));
assert!(!state.is_in_time_window(0, 1000));
assert!(!state.is_in_time_window(1, 2000)); }
#[test]
fn test_time_window_150s_exact_boundary() {
let state = EngineState::new(Bytes::from_static(b"engine"), 1, 10000);
assert!(
state.is_in_time_window(1, 10150),
"Message at exactly +150s boundary should be in window"
);
assert!(
!state.is_in_time_window(1, 10151),
"Message at +151s should be outside window"
);
assert!(
state.is_in_time_window(1, 9850),
"Message at exactly -150s boundary should be in window"
);
assert!(
!state.is_in_time_window(1, 9849),
"Message at -151s should be outside window"
);
}
#[test]
fn test_time_window_boots_latched() {
let state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_647, 1000);
assert!(
!state.is_in_time_window(2_147_483_647, 1000),
"Latched boots should reject all messages"
);
assert!(!state.is_in_time_window(2_147_483_647, 1100));
assert!(!state.is_in_time_window(2_147_483_647, 900));
}
#[test]
fn test_time_window_boots_mismatch() {
let state = EngineState::new(Bytes::from_static(b"engine"), 100, 1000);
assert!(!state.is_in_time_window(101, 1000));
assert!(!state.is_in_time_window(200, 1000));
assert!(!state.is_in_time_window(99, 1000));
assert!(!state.is_in_time_window(0, 1000));
}
#[test]
fn test_check_and_update_timeliness_within_window_accepted() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
assert!(state.check_and_update_timeliness(3, 900));
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 1000);
assert!(state.check_and_update_timeliness(3, 850));
}
#[test]
fn test_check_and_update_timeliness_controllable_boundary_without_rollback() {
let now = Instant::now();
let mut at_boundary = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
at_boundary.trusted_time.as_mut().unwrap().received_at = now;
assert!(at_boundary.check_and_update_timeliness_at(3, 950, now + Duration::from_secs(100)));
assert_eq!(
at_boundary.trusted_time().unwrap().latest_received_time(),
1000,
"an older in-window message must not lower the high-water mark"
);
let mut outside = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
outside.trusted_time.as_mut().unwrap().received_at = now;
assert!(!outside.check_and_update_timeliness_at(3, 949, now + Duration::from_secs(100)));
assert_eq!(outside.trusted_time().unwrap().latest_received_time(), 1000);
}
#[test]
fn test_check_and_update_timeliness_newer_time_updates_lcd() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
assert!(state.check_and_update_timeliness(3, 1200));
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 1200);
assert_eq!(state.trusted_time().unwrap().received_time_base(), 1200);
}
#[test]
fn test_check_and_update_timeliness_stale_time_rejected() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
assert!(!state.check_and_update_timeliness(3, 500));
assert!(!state.check_and_update_timeliness(3, 849));
}
#[test]
fn test_check_and_update_timeliness_old_boots_rejected() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
assert!(!state.check_and_update_timeliness(2, 5000));
assert_eq!(
state.trusted_time().unwrap().boots(),
3,
"old boot cycle must not update LCD"
);
}
#[test]
fn test_check_and_update_timeliness_reboot_accepted() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
assert!(state.check_and_update_timeliness(4, 10));
assert_eq!(state.trusted_time().unwrap().boots(), 4);
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 10);
assert!(!state.check_and_update_timeliness(3, 99999));
}
#[test]
fn test_check_and_update_timeliness_latched_boots_rejected() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), MAX_ENGINE_TIME, 1000);
assert!(!state.check_and_update_timeliness(MAX_ENGINE_TIME, 1000));
}
#[test]
fn test_engine_cache_basic_operations() {
let cache = EngineCache::new();
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
assert!(cache.is_empty());
assert!(cache.get(&addr).is_none());
let state = EngineState::new(Bytes::from_static(b"engine1"), 1, 1000);
cache.insert(addr, state);
assert_eq!(cache.len(), 1);
assert!(!cache.is_empty());
let retrieved = cache.get(&addr).unwrap();
assert_eq!(retrieved.engine_id.as_ref(), b"engine1");
assert_eq!(retrieved.trusted_time().unwrap().boots(), 1);
assert!(cache.update_time(&addr, 1, 1100));
let removed = cache.remove(&addr).unwrap();
assert_eq!(removed.trusted_time().unwrap().latest_received_time(), 1100);
assert!(cache.is_empty());
}
#[test]
fn test_engine_cache_explicit_replacement_latches_new_identity() {
let cache = EngineCache::new();
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
let shared_addr: SocketAddr = "192.168.1.2:161".parse().unwrap();
let old = EngineState::discovered(Bytes::from_static(b"old-engine"), 1400);
let new = EngineState::discovered(Bytes::from_static(b"new-engine"), 1500);
let shared = EngineState::new(Bytes::from_static(b"new-engine"), 7, 500);
cache.insert(addr, old.clone());
cache.insert(shared_addr, shared);
let replaced = cache.replace_target(addr, new).unwrap();
cache.insert(addr, old);
assert_eq!(replaced.engine_id().as_ref(), b"new-engine");
let trusted = replaced.trusted_time().unwrap();
assert_eq!((trusted.boots(), trusted.latest_received_time()), (7, 500));
let cached = cache.get(&addr).unwrap();
assert_eq!(cached.engine_id().as_ref(), b"new-engine");
assert_eq!(cached.msg_max_size, 1500);
}
#[test]
fn test_engine_cache_shares_trusted_time_by_engine_id() {
let cache = EngineCache::new();
let addr1: SocketAddr = "192.168.1.1:161".parse().unwrap();
let addr2: SocketAddr = "192.168.1.2:161".parse().unwrap();
let engine_id = Bytes::from_static(b"shared-engine");
cache.insert(addr1, EngineState::discovered(engine_id.clone(), 1400));
cache.insert(addr2, EngineState::discovered(engine_id, 1500));
assert!(cache.update_time(&addr1, 4, 500));
let state2 = cache.get(&addr2).unwrap();
let trusted = state2.trusted_time().unwrap();
assert_eq!((trusted.boots(), trusted.latest_received_time()), (4, 500));
assert_eq!(state2.msg_max_size, 1500);
}
#[test]
fn test_engine_cache_stale_clone_cannot_overwrite_newer_time() {
let cache = EngineCache::new();
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
let engine_id = Bytes::from_static(b"engine1");
cache.insert(addr, EngineState::new(engine_id.clone(), 7, 500));
cache.insert(addr, EngineState::new(engine_id.clone(), 6, 9000));
cache.insert(addr, EngineState::discovered(engine_id, 1400));
let state = cache.get(&addr).unwrap();
let trusted = state.trusted_time().unwrap();
assert_eq!((trusted.boots(), trusted.latest_received_time()), (7, 500));
}
#[test]
fn test_engine_cache_concurrent_updates_converge_monotonically() {
use std::sync::Arc;
let cache = Arc::new(EngineCache::new());
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
cache.insert(
addr,
EngineState::discovered(Bytes::from_static(b"engine1"), 1400),
);
let older = Arc::clone(&cache);
let newer = Arc::clone(&cache);
let older_task = std::thread::spawn(move || {
for _ in 0..100 {
older.update_time(&addr, 4, 9000);
}
});
let newer_task = std::thread::spawn(move || {
for _ in 0..100 {
newer.update_time(&addr, 5, 10);
}
});
older_task.join().unwrap();
newer_task.join().unwrap();
let state = cache.get(&addr).unwrap();
let trusted = state.trusted_time().unwrap();
assert_eq!((trusted.boots(), trusted.latest_received_time()), (5, 10));
}
#[test]
fn test_engine_cache_ttl_expiry() {
let cache = EngineCache::new().with_ttl(Duration::from_secs(5));
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
let now = Instant::now();
let state = EngineState::new(Bytes::from_static(b"engine1"), 1, 1000);
cache.insert_at(addr, state, now);
assert!(cache.get_at(&addr, now + Duration::from_secs(5)).is_some());
assert!(
cache.get_at(&addr, now + Duration::from_secs(6)).is_none(),
"expired entry should return None"
);
assert!(cache.is_empty(), "expired entry should be removed");
}
#[test]
fn test_engine_cache_ttl_refresh_on_every_accepted_authenticated_message() {
let cache = EngineCache::new().with_ttl(Duration::from_secs(5));
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
let now = Instant::now();
let engine_id = Bytes::from_static(b"engine1");
let local_state = EngineState::new(engine_id.clone(), 1, 1000);
cache.insert_at(addr, local_state.clone(), now);
let (timely, _) = cache
.check_and_update_timeliness_at(
&addr,
&local_state,
&engine_id,
1,
900,
now + Duration::from_secs(4),
)
.unwrap();
assert!(timely, "older in-window input remains acceptable");
assert!(
cache.get_at(&addr, now + Duration::from_secs(8)).is_some(),
"accepted authenticated input must refresh TTL without advancing high-water"
);
}
#[test]
fn test_engine_cache_live_state_prevents_rebuilt_cache_from_accepting_old_boots() {
let cache = EngineCache::new();
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
let now = Instant::now();
let engine_id = Bytes::from_static(b"engine1");
let mut local_state = EngineState::new(engine_id.clone(), 5, 1000);
local_state.trusted_time.as_mut().unwrap().received_at = now;
cache.insert_at(addr, EngineState::discovered(engine_id.clone(), 1400), now);
let (timely, canonical) = cache
.check_and_update_timeliness_at(
&addr,
&local_state,
&engine_id,
4,
5000,
now + Duration::from_secs(1),
)
.unwrap();
assert!(!timely, "rebuilt cache must not weaken live client state");
let trusted = canonical.trusted_time().unwrap();
assert_eq!((trusted.boots(), trusted.latest_received_time()), (5, 1000));
}
#[test]
fn test_engine_cache_rejected_message_does_not_refresh_existing_entry() {
let cache = EngineCache::new().with_ttl(Duration::from_secs(5));
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
let now = Instant::now();
let engine_id = Bytes::from_static(b"engine1");
let mut local_state = EngineState::new(engine_id.clone(), 5, 1000);
local_state.trusted_time.as_mut().unwrap().received_at = now;
cache.insert_at(addr, local_state.clone(), now);
let (timely, _) = cache
.check_and_update_timeliness_at(
&addr,
&local_state,
&engine_id,
4,
5000,
now + Duration::from_secs(4),
)
.unwrap();
assert!(!timely);
assert!(cache.get_at(&addr, now + Duration::from_secs(6)).is_none());
}
#[test]
fn test_engine_cache_rejected_message_does_not_resurrect_expired_entry() {
let cache = EngineCache::new().with_ttl(Duration::from_secs(5));
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
let now = Instant::now();
let engine_id = Bytes::from_static(b"engine1");
let mut local_state = EngineState::new(engine_id.clone(), 5, 1000);
local_state.trusted_time.as_mut().unwrap().received_at = now;
cache.insert_at(addr, local_state.clone(), now);
let (timely, _) = cache
.check_and_update_timeliness_at(
&addr,
&local_state,
&engine_id,
4,
5000,
now + Duration::from_secs(6),
)
.unwrap();
assert!(!timely);
assert!(cache.get_at(&addr, now + Duration::from_secs(6)).is_none());
assert!(cache.is_empty());
}
#[test]
fn test_engine_cache_max_capacity_eviction() {
let cache = EngineCache::new().with_max_capacity(2);
let addr1: SocketAddr = "192.168.1.1:161".parse().unwrap();
let addr2: SocketAddr = "192.168.1.2:161".parse().unwrap();
let addr3: SocketAddr = "192.168.1.3:161".parse().unwrap();
let now = Instant::now();
cache.insert_at(
addr1,
EngineState::new(Bytes::from_static(b"e1"), 1, 100),
now,
);
cache.insert_at(
addr2,
EngineState::new(Bytes::from_static(b"e2"), 1, 200),
now + Duration::from_secs(1),
);
assert_eq!(cache.len(), 2);
cache.insert_at(
addr3,
EngineState::new(Bytes::from_static(b"e3"), 1, 300),
now + Duration::from_secs(2),
);
assert_eq!(cache.len(), 2);
assert!(
cache.get(&addr1).is_none(),
"oldest entry should be evicted"
);
assert!(cache.get(&addr2).is_some());
assert!(cache.get(&addr3).is_some());
}
#[test]
fn test_parse_discovery_response() {
let usm = UsmSecurityParams::new(b"test-engine-id".as_slice(), 42, 12345, b"".as_slice());
let encoded = usm.encode();
let state = parse_discovery_response(&encoded).unwrap();
assert_eq!(state.engine_id.as_ref(), b"test-engine-id");
assert!(state.trusted_time().is_none());
assert_eq!(state.estimated_boots_time(), (0, 0));
}
#[test]
fn test_parse_discovery_response_empty_engine_id() {
let usm = UsmSecurityParams::empty();
let encoded = usm.encode();
let result = parse_discovery_response(&encoded);
assert!(matches!(
*result.unwrap_err(),
Error::MalformedResponse { .. }
));
}
#[test]
fn test_parse_discovery_response_rejects_invalid_engine_id() {
let usm = UsmSecurityParams::new(b"abcd".as_slice(), 1, 1, b"".as_slice());
assert!(matches!(
*parse_discovery_response(&usm.encode()).unwrap_err(),
Error::MalformedResponse { .. }
));
let usm = UsmSecurityParams::new([0u8; 8].as_slice(), 1, 1, b"".as_slice());
assert!(matches!(
*parse_discovery_response(&usm.encode()).unwrap_err(),
Error::MalformedResponse { .. }
));
let usm = UsmSecurityParams::new([0xffu8; 8].as_slice(), 1, 1, b"".as_slice());
assert!(matches!(
*parse_discovery_response(&usm.encode()).unwrap_err(),
Error::MalformedResponse { .. }
));
}
#[test]
fn test_engine_boots_transition_to_max() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_646, 1000);
assert!(
state.update_time(2_147_483_647, 100),
"Transition to boots=2_147_483_647 should be accepted"
);
assert_eq!(state.trusted_time().unwrap().boots(), 2_147_483_647);
assert_eq!(state.trusted_time().unwrap().received_time_base(), 100);
}
#[test]
fn test_engine_boots_latched_update_behavior() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_647, 1000);
assert!(
state.update_time(2_147_483_647, 2000),
"Time tracking updates should still work"
);
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 2000);
assert!(!state.update_time(2_147_483_647, 1500));
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 2000);
assert!(
!state.is_in_time_window(2_147_483_647, 2000),
"Latched state should still reject all messages"
);
}
#[test]
fn test_engine_boots_latched_time_window_always_fails() {
let state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_647, 1000);
assert!(!state.is_in_time_window(2_147_483_647, 0));
assert!(!state.is_in_time_window(2_147_483_647, 1000));
assert!(!state.is_in_time_window(2_147_483_647, 1001));
assert!(!state.is_in_time_window(2_147_483_647, u32::MAX));
assert!(!state.is_in_time_window(2_147_483_646, 1000));
assert!(!state.is_in_time_window(0, 1000));
}
#[test]
fn test_engine_state_created_latched() {
let state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_647, 5000);
assert_eq!(state.trusted_time().unwrap().boots(), 2_147_483_647);
assert_eq!(state.trusted_time().unwrap().received_time_base(), 5000);
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 5000);
assert!(
!state.is_in_time_window(2_147_483_647, 5000),
"Newly created latched engine should reject all messages"
);
}
#[test]
fn test_engine_boots_near_max_operates_normally() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_645, 1000);
assert!(state.is_in_time_window(2_147_483_645, 1000));
assert!(state.is_in_time_window(2_147_483_645, 1100));
assert!(!state.is_in_time_window(2_147_483_645, 1200));
assert!(state.update_time(2_147_483_646, 500));
assert_eq!(state.trusted_time().unwrap().boots(), 2_147_483_646);
assert!(state.is_in_time_window(2_147_483_646, 500));
assert!(state.update_time(2_147_483_647, 100));
assert_eq!(state.trusted_time().unwrap().boots(), 2_147_483_647);
assert!(!state.is_in_time_window(2_147_483_647, 100));
}
#[test]
fn test_engine_boots_high_value_update_logic() {
let mut state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_640, 1000);
assert!(!state.update_time(2147483639, 9999));
assert!(!state.update_time(0, 9999));
assert!(!state.update_time(2_147_483_640, 500));
assert!(state.update_time(2_147_483_640, 1500));
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 1500);
assert!(state.update_time(2_147_483_641, 100));
assert_eq!(state.trusted_time().unwrap().boots(), 2_147_483_641);
}
#[test]
fn test_engine_cache_latched_engine() {
let cache = EngineCache::new();
let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
cache.insert(
addr,
EngineState::new(Bytes::from_static(b"latched"), 2_147_483_647, 1000),
);
assert!(
cache.update_time(&addr, 2_147_483_647, 2000),
"Time tracking should update even for latched engine"
);
let state = cache.get(&addr).unwrap();
assert_eq!(state.trusted_time().unwrap().latest_received_time(), 2000);
assert!(
!state.is_in_time_window(2_147_483_647, 2000),
"Latched engine should reject all time window checks"
);
}
#[test]
fn test_engine_state_stores_msg_max_size() {
let state = EngineState::with_msg_max_size(Bytes::from_static(b"engine"), 1, 1000, 65507);
assert_eq!(state.msg_max_size, 65507);
}
#[test]
fn test_engine_state_default_msg_max_size() {
let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
assert_eq!(
state.msg_max_size, DEFAULT_MSG_MAX_SIZE,
"Default msg_max_size should be the maximum UDP datagram size"
);
}
#[test]
fn test_engine_state_msg_max_size_capped_to_session_max() {
let state = EngineState::with_msg_max_size_capped(
Bytes::from_static(b"engine"),
1,
1000,
2_000_000_000, 65507, );
assert_eq!(
state.msg_max_size, 65507,
"msg_max_size should be capped to session maximum"
);
}
#[test]
fn test_engine_state_msg_max_size_within_limit_not_capped() {
let state = EngineState::with_msg_max_size_capped(
Bytes::from_static(b"engine"),
1,
1000,
1472, 65507, );
assert_eq!(
state.msg_max_size, 1472,
"msg_max_size within limit should not be capped"
);
}
#[test]
fn test_engine_state_msg_max_size_at_exact_boundary() {
let state = EngineState::with_msg_max_size_capped(
Bytes::from_static(b"engine"),
1,
1000,
65507, 65507, );
assert_eq!(state.msg_max_size, 65507);
}
#[test]
fn test_engine_state_msg_max_size_tcp_limit() {
const TCP_MAX: u32 = 0x7FFF_FFFF;
let state = EngineState::with_msg_max_size_capped(
Bytes::from_static(b"engine"),
1,
1000,
TCP_MAX,
TCP_MAX,
);
assert_eq!(state.msg_max_size, TCP_MAX);
let state = EngineState::with_msg_max_size_capped(
Bytes::from_static(b"engine"),
1,
1000,
u32::MAX, TCP_MAX,
);
assert_eq!(
state.msg_max_size, TCP_MAX,
"Values exceeding session max should be capped"
);
}
#[test]
fn test_engine_state_new_uses_default_constant() {
let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
assert_eq!(state.msg_max_size, DEFAULT_MSG_MAX_SIZE);
}
#[test]
fn test_estimated_time_caps_at_max_engine_time() {
let state = EngineState::new(Bytes::from_static(b"engine"), 1, MAX_ENGINE_TIME - 10);
let estimated = state.estimated_time();
assert!(
estimated <= MAX_ENGINE_TIME,
"estimated_time() should never exceed MAX_ENGINE_TIME ({MAX_ENGINE_TIME}), got {estimated}"
);
}
#[test]
fn test_estimated_pair_rolls_after_max_engine_time() {
let now = Instant::now();
let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 0);
state.trusted_time.as_mut().unwrap().received_at = now;
assert_eq!(
state.estimated_boots_time_at(now + Duration::from_secs(u64::from(MAX_ENGINE_TIME))),
(1, MAX_ENGINE_TIME)
);
assert_eq!(
state
.estimated_boots_time_at(now + Duration::from_secs(u64::from(MAX_ENGINE_TIME) + 1)),
(2, 0)
);
}
#[test]
fn test_max_engine_time_tuple_remains_timely() {
let now = Instant::now();
let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, MAX_ENGINE_TIME);
state.trusted_time.as_mut().unwrap().received_at = now;
assert!(state.check_and_update_timeliness_at(1, MAX_ENGINE_TIME, now));
assert_eq!(state.estimated_boots_time_at(now), (1, MAX_ENGINE_TIME));
}
#[test]
fn test_max_engine_time_constant() {
assert_eq!(MAX_ENGINE_TIME, 2_147_483_647);
assert_eq!(MAX_ENGINE_TIME, i32::MAX as u32);
}
#[test]
fn test_estimated_time_normal_operation() {
let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
let estimated = state.estimated_time();
assert!(
estimated >= 1000,
"estimated_time() should be at least engine_time"
);
assert!(
estimated < MAX_ENGINE_TIME,
"Normal time values should not hit MAX_ENGINE_TIME cap"
);
}
}