async_snmp/v3/engine.rs
1//! Engine discovery and time synchronization (RFC 3414 Section 4).
2//!
3//! `SNMPv3` requires knowing the authoritative engine's ID, boots counter,
4//! and time value before authenticated messages can be sent. This module
5//! provides:
6//!
7//! - `EngineCache`: Thread-safe cache of discovered engine state
8//! - `EngineState`: Per-engine state (ID, boots, time)
9//! - Discovery response parsing
10//!
11//! # Discovery Flow
12//!
13//! 1. Client sends discovery request (noAuthNoPriv, empty engine ID)
14//! 2. Agent responds with Report PDU containing usmStatsUnknownEngineIDs
15//! 3. Response's USM params contain the engine ID, boots, and time
16//! 4. Client caches these values for subsequent authenticated requests
17//!
18//! # Time Synchronization
19//!
20//! Per RFC 3414 Section 2.3, a non-authoritative engine (client) maintains:
21//! - `snmpEngineBoots`: Boot counter from authoritative engine
22//! - `snmpEngineTime`: Time value from authoritative engine
23//! - `latestReceivedEngineTime`: Highest time received (anti-replay)
24//!
25//! The time window is 150 seconds. Messages outside this window are rejected.
26
27use std::collections::HashMap;
28use std::net::SocketAddr;
29use std::sync::RwLock;
30use std::time::{Duration, Instant};
31
32use bytes::Bytes;
33
34use crate::error::{Error, Result};
35use crate::v3::UsmSecurityParams;
36
37/// Time window in seconds (RFC 3414 Section 2.2.3).
38pub const TIME_WINDOW: u32 = 150;
39
40/// Maximum valid snmpEngineTime value (RFC 3414 Section 2.2.1).
41///
42/// Per RFC 3414, snmpEngineTime is a 31-bit value (0..2,147,483,647).
43/// When the value reaches this maximum, the authoritative engine should
44/// reset it to zero and increment snmpEngineBoots.
45pub const MAX_ENGINE_TIME: u32 = 2_147_483_647;
46
47/// Default msgMaxSize for UDP transport (65535 - 20 IPv4 - 8 UDP = 65507).
48pub const DEFAULT_MSG_MAX_SIZE: u32 = 65507;
49
50/// Compute engine boots and time from a base boots value and total elapsed
51/// seconds since engine start.
52///
53/// Per RFC 3414 Section 2.3, each time the elapsed seconds reaches
54/// `MAX_ENGINE_TIME` (2^31-1), boots increments by one and time wraps to zero.
55/// The boots value is capped at `MAX_ENGINE_TIME` (the "latched" state per
56/// RFC 3414 Section 2.2.3).
57#[must_use]
58pub fn compute_engine_boots_time(boots_base: u32, total_elapsed_secs: u64) -> (u32, u32) {
59 let max = u64::from(MAX_ENGINE_TIME);
60 let additional_boots = total_elapsed_secs / max;
61 let current_time = (total_elapsed_secs % max) as u32;
62 let boots = (u64::from(boots_base) + additional_boots).min(max) as u32;
63 (boots, current_time)
64}
65
66/// USM statistics OIDs used in Report PDUs.
67pub mod report_oids {
68 use crate::Oid;
69 use crate::oid;
70
71 /// 1.3.6.1.6.3.15.1.1.1.0 - usmStatsUnsupportedSecLevels
72 #[must_use]
73 pub fn unsupported_sec_levels() -> Oid {
74 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 1, 0)
75 }
76
77 /// 1.3.6.1.6.3.15.1.1.2.0 - usmStatsNotInTimeWindows
78 #[must_use]
79 pub fn not_in_time_windows() -> Oid {
80 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 2, 0)
81 }
82
83 /// 1.3.6.1.6.3.15.1.1.3.0 - usmStatsUnknownUserNames
84 #[must_use]
85 pub fn unknown_user_names() -> Oid {
86 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 3, 0)
87 }
88
89 /// 1.3.6.1.6.3.15.1.1.4.0 - usmStatsUnknownEngineIDs
90 #[must_use]
91 pub fn unknown_engine_ids() -> Oid {
92 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 4, 0)
93 }
94
95 /// 1.3.6.1.6.3.15.1.1.5.0 - usmStatsWrongDigests
96 #[must_use]
97 pub fn wrong_digests() -> Oid {
98 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 5, 0)
99 }
100
101 /// 1.3.6.1.6.3.15.1.1.6.0 - usmStatsDecryptionErrors
102 #[must_use]
103 pub fn decryption_errors() -> Oid {
104 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 6, 0)
105 }
106}
107
108/// Discovered engine state.
109#[derive(Debug, Clone)]
110pub struct EngineState {
111 /// Authoritative engine ID
112 pub engine_id: Bytes,
113 /// Engine boot count
114 pub engine_boots: u32,
115 /// Engine time at last sync
116 pub engine_time: u32,
117 /// Local time when `engine_time` was received
118 pub synced_at: Instant,
119 /// Latest received engine time (for anti-replay, RFC 3414 Section 2.3)
120 pub latest_received_engine_time: u32,
121 /// Maximum message size the remote engine can accept (from msgMaxSize header).
122 pub msg_max_size: u32,
123}
124
125impl EngineState {
126 /// Create new engine state from discovery response.
127 pub fn new(engine_id: Bytes, engine_boots: u32, engine_time: u32) -> Self {
128 Self {
129 engine_id,
130 engine_boots,
131 engine_time,
132 synced_at: Instant::now(),
133 latest_received_engine_time: engine_time,
134 msg_max_size: DEFAULT_MSG_MAX_SIZE,
135 }
136 }
137
138 /// Create with explicit msgMaxSize from agent's header.
139 pub fn with_msg_max_size(
140 engine_id: Bytes,
141 engine_boots: u32,
142 engine_time: u32,
143 msg_max_size: u32,
144 ) -> Self {
145 Self {
146 engine_id,
147 engine_boots,
148 engine_time,
149 synced_at: Instant::now(),
150 latest_received_engine_time: engine_time,
151 msg_max_size,
152 }
153 }
154
155 /// Create with msgMaxSize capped to session maximum.
156 ///
157 /// Non-compliant agents may advertise msgMaxSize values larger than they
158 /// can handle. This caps the value to a known safe session limit.
159 pub fn with_msg_max_size_capped(
160 engine_id: Bytes,
161 engine_boots: u32,
162 engine_time: u32,
163 reported_msg_max_size: u32,
164 session_max: u32,
165 ) -> Self {
166 let msg_max_size = if reported_msg_max_size > session_max {
167 tracing::debug!(target: "async_snmp::v3", { reported = reported_msg_max_size, session_max = session_max }, "capping msgMaxSize to session limit");
168 session_max
169 } else {
170 reported_msg_max_size
171 };
172
173 Self {
174 engine_id,
175 engine_boots,
176 engine_time,
177 synced_at: Instant::now(),
178 latest_received_engine_time: engine_time,
179 msg_max_size,
180 }
181 }
182
183 /// Get the estimated current engine time.
184 ///
185 /// This adds elapsed local time to the synced engine time.
186 /// Per RFC 3414 Section 2.2.1, the result is capped at `MAX_ENGINE_TIME`
187 /// (2^31-1).
188 ///
189 /// Note: the client does not locally increment `engine_boots` when the
190 /// estimated time reaches `MAX_ENGINE_TIME`. The authoritative engine
191 /// (agent) is responsible for the boots increment; the client will
192 /// learn the new boots value from the agent's next response or from
193 /// a notInTimeWindow Report. Until that happens, the capped time is
194 /// the best estimate the client can produce.
195 pub fn estimated_time(&self) -> u32 {
196 let elapsed = self.synced_at.elapsed().as_secs() as u32;
197 self.engine_time
198 .saturating_add(elapsed)
199 .min(MAX_ENGINE_TIME)
200 }
201
202 /// Update time from a response.
203 ///
204 /// Per RFC 3414 Section 3.2 Step 7b, only update if:
205 /// - Response boots > local boots, OR
206 /// - Response boots == local boots AND response time > `latest_received_engine_time`
207 pub fn update_time(&mut self, response_boots: u32, response_time: u32) -> bool {
208 if response_boots > self.engine_boots {
209 // New boot cycle
210 self.engine_boots = response_boots;
211 self.engine_time = response_time;
212 self.synced_at = Instant::now();
213 self.latest_received_engine_time = response_time;
214 true
215 } else if response_boots == self.engine_boots
216 && response_time > self.latest_received_engine_time
217 {
218 // Same boot cycle, newer time
219 self.engine_time = response_time;
220 self.synced_at = Instant::now();
221 self.latest_received_engine_time = response_time;
222 true
223 } else {
224 false
225 }
226 }
227
228 /// Timeliness check for messages from a remote authoritative engine
229 /// (RFC 3414 Section 3.2 Step 7b, non-authoritative role).
230 ///
231 /// First updates the local notion of the remote engine's boots/time if
232 /// the message is newer (see [`update_time`](Self::update_time)), then
233 /// evaluates the asymmetric time window: the message is outside the
234 /// window only if the local boots notion is latched at the maximum,
235 /// the message's boots value is older than the local notion, or the
236 /// message's time is more than 150 seconds behind the local notion.
237 ///
238 /// The caller must verify the message is authentic before calling this,
239 /// since it mutates the timeliness state.
240 ///
241 /// Returns true if the message is within the time window.
242 pub fn check_and_update_timeliness(&mut self, msg_boots: u32, msg_time: u32) -> bool {
243 self.update_time(msg_boots, msg_time);
244
245 if self.engine_boots == MAX_ENGINE_TIME {
246 return false;
247 }
248 if msg_boots < self.engine_boots {
249 return false;
250 }
251 if msg_boots == self.engine_boots
252 && msg_time < self.estimated_time().saturating_sub(TIME_WINDOW)
253 {
254 return false;
255 }
256 true
257 }
258
259 /// Check if a message time is within the time window.
260 ///
261 /// Per RFC 3414 Section 2.2.3, a message is outside the window if:
262 /// - Local boots is 2,147,483,647 (latched), OR
263 /// - Message boots differs from local boots, OR
264 /// - |`message_time` - `local_time`| > 150 seconds
265 pub fn is_in_time_window(&self, msg_boots: u32, msg_time: u32) -> bool {
266 in_authoritative_time_window(
267 self.engine_boots,
268 self.estimated_time(),
269 msg_boots,
270 msg_time,
271 )
272 }
273}
274
275/// Time window check when the local engine's boots/time are the reference
276/// (RFC 3414 Section 2.2.3, applied by Section 3.2 Step 7a in the
277/// authoritative role).
278///
279/// The message is in the window only if local boots is not latched at
280/// [`MAX_ENGINE_TIME`], the message boots equals local boots, and the message
281/// time is within [`TIME_WINDOW`] seconds of local time (symmetric).
282///
283/// For messages from a remote authoritative engine (Step 7b), use
284/// [`EngineState::check_and_update_timeliness`] instead: that check is
285/// asymmetric and self-updating.
286pub fn in_authoritative_time_window(
287 local_boots: u32,
288 local_time: u32,
289 msg_boots: u32,
290 msg_time: u32,
291) -> bool {
292 local_boots != MAX_ENGINE_TIME
293 && msg_boots == local_boots
294 && msg_time.abs_diff(local_time) <= TIME_WINDOW
295}
296
297/// Default TTL for engine cache entries (5 minutes).
298///
299/// Entries not refreshed by a successful authenticated exchange within
300/// this duration are considered stale. This handles device replacement
301/// (new engine ID at the same IP) without requiring unauthenticated
302/// re-discovery on Report PDUs.
303const DEFAULT_ENGINE_CACHE_TTL: Duration = Duration::from_secs(300);
304
305/// Thread-safe cache of discovered `SNMPv3` engine state.
306///
307/// Before sending authenticated `SNMPv3` messages, a client must discover
308/// the target engine's ID, boot counter, and time (RFC 3414 Section 4).
309/// This cache stores those results so that subsequent requests, or other
310/// clients sharing the same cache via [`Arc`](std::sync::Arc), skip the discovery round trip.
311///
312/// # Entry lifetime
313///
314/// Each entry tracks a `synced_at` timestamp that is reset on every
315/// successful time update ([`update_time`](Self::update_time)). Entries
316/// whose `synced_at` exceeds the configured TTL (default 5 minutes) are
317/// treated as expired: [`get`](Self::get) returns `None` and the stale
318/// entry is removed, causing the next request to re-run discovery.
319///
320/// This TTL-based expiry handles **device replacement** (a new device with
321/// a different engine ID appearing at the same IP address). Without it,
322/// the client would hold a stale engine ID indefinitely and every request
323/// would fail with `usmStatsUnknownEngineIDs`. Automatic re-discovery on
324/// that Report PDU was considered but rejected because Report PDUs are
325/// unauthenticated, making it possible for a spoofed report to force
326/// re-discovery toward a rogue engine. The TTL approach avoids this: only
327/// entries that have not been refreshed by a successful authenticated
328/// exchange are expired.
329///
330/// Actively polled targets refresh their entry on every response, so the
331/// TTL has no effect during normal operation.
332///
333/// # Capacity
334///
335/// The cache is unbounded by default. Each entry is roughly 100-150 bytes,
336/// so even 100k targets uses only ~10-15 MB. For deployments that scan
337/// very large address ranges, [`with_max_capacity`](Self::with_max_capacity)
338/// sets a hard limit with oldest-entry eviction.
339///
340/// # Example
341///
342/// ```ignore
343/// use std::sync::Arc;
344///
345/// let cache = Arc::new(EngineCache::new());
346///
347/// let client1 = Client::builder("192.168.1.1:161")
348/// .username("admin")
349/// .auth(AuthProtocol::Sha1, "authpass")
350/// .engine_cache(cache.clone())
351/// .connect()
352/// .await?;
353///
354/// let client2 = Client::builder("192.168.1.2:161")
355/// .username("admin")
356/// .auth(AuthProtocol::Sha1, "authpass")
357/// .engine_cache(cache.clone())
358/// .connect()
359/// .await?;
360/// ```
361#[derive(Debug)]
362pub struct EngineCache {
363 engines: RwLock<HashMap<SocketAddr, EngineState>>,
364 max_capacity: Option<usize>,
365 ttl: Duration,
366}
367
368impl Default for EngineCache {
369 fn default() -> Self {
370 Self::new()
371 }
372}
373
374impl EngineCache {
375 /// Create a new empty engine cache with default settings.
376 #[must_use]
377 pub fn new() -> Self {
378 Self {
379 engines: RwLock::new(HashMap::new()),
380 max_capacity: None,
381 ttl: DEFAULT_ENGINE_CACHE_TTL,
382 }
383 }
384
385 /// Set a maximum capacity. When full, the oldest entry is evicted on insert.
386 #[must_use]
387 pub fn with_max_capacity(mut self, max_capacity: usize) -> Self {
388 self.max_capacity = Some(max_capacity.max(1));
389 self
390 }
391
392 /// Set the TTL for cache entries. Entries not refreshed within this
393 /// duration are removed on lookup, triggering re-discovery.
394 #[must_use]
395 pub fn with_ttl(mut self, ttl: Duration) -> Self {
396 self.ttl = ttl;
397 self
398 }
399
400 /// Get cached engine state for a target.
401 ///
402 /// Returns `None` if the entry does not exist or has expired.
403 /// Expired entries are removed from the cache.
404 pub fn get(&self, target: &SocketAddr) -> Option<EngineState> {
405 // Fast path: read lock, check existence and TTL.
406 {
407 let engines = self.engines.read().ok()?;
408 match engines.get(target) {
409 None => return None,
410 Some(state) if state.synced_at.elapsed() <= self.ttl => {
411 return Some(state.clone());
412 }
413 Some(_) => {} // expired, fall through to evict
414 }
415 }
416 // Slow path: write lock to remove the stale entry.
417 if let Ok(mut engines) = self.engines.write()
418 && let Some(state) = engines.get(target)
419 && state.synced_at.elapsed() > self.ttl
420 {
421 engines.remove(target);
422 }
423 None
424 }
425
426 /// Store engine state for a target.
427 ///
428 /// If a max capacity is set and the cache is full, the entry with
429 /// the oldest `synced_at` time is evicted.
430 pub fn insert(&self, target: SocketAddr, state: EngineState) {
431 if let Ok(mut engines) = self.engines.write() {
432 if let Some(cap) = self.max_capacity
433 && !engines.contains_key(&target)
434 && engines.len() >= cap
435 && let Some(oldest) = engines
436 .iter()
437 .min_by_key(|(_, s)| s.synced_at)
438 .map(|(k, _)| *k)
439 {
440 engines.remove(&oldest);
441 }
442 engines.insert(target, state);
443 }
444 }
445
446 /// Update time for an existing entry.
447 ///
448 /// Returns true if the entry was updated, false if not found or not updated.
449 pub fn update_time(
450 &self,
451 target: &SocketAddr,
452 response_boots: u32,
453 response_time: u32,
454 ) -> bool {
455 if let Ok(mut engines) = self.engines.write()
456 && let Some(state) = engines.get_mut(target)
457 {
458 return state.update_time(response_boots, response_time);
459 }
460 false
461 }
462
463 /// Remove cached state for a target.
464 pub fn remove(&self, target: &SocketAddr) -> Option<EngineState> {
465 self.engines.write().ok()?.remove(target)
466 }
467
468 /// Clear all cached state.
469 pub fn clear(&self) {
470 if let Ok(mut engines) = self.engines.write() {
471 engines.clear();
472 }
473 }
474
475 /// Get the number of cached engines (including expired entries).
476 pub fn len(&self) -> usize {
477 self.engines.read().map_or(0, |e| e.len())
478 }
479
480 /// Check if the cache is empty.
481 pub fn is_empty(&self) -> bool {
482 self.len() == 0
483 }
484}
485
486/// Extract engine state from a discovery response's USM security parameters.
487///
488/// The discovery response (Report PDU) contains the authoritative engine's
489/// ID, boots, and time in the USM security parameters field.
490pub fn parse_discovery_response(security_params: &Bytes) -> Result<EngineState> {
491 parse_discovery_response_with_limits(
492 security_params,
493 DEFAULT_MSG_MAX_SIZE,
494 DEFAULT_MSG_MAX_SIZE,
495 )
496}
497
498/// Extract engine state with explicit msgMaxSize and session limit.
499///
500/// The `reported_msg_max_size` comes from the V3 message header (`MsgGlobalData`).
501/// The `session_max` is our transport's maximum message size.
502/// Values are capped to prevent issues with non-compliant agents.
503pub fn parse_discovery_response_with_limits(
504 security_params: &Bytes,
505 reported_msg_max_size: u32,
506 session_max: u32,
507) -> Result<EngineState> {
508 let usm = UsmSecurityParams::decode(security_params.clone())?;
509
510 if usm.engine_id.is_empty() {
511 tracing::debug!(target: "async_snmp::engine", "discovery response contained empty engine ID");
512 return Err(Error::MalformedResponse {
513 target: SocketAddr::from(([0, 0, 0, 0], 0)),
514 }
515 .boxed());
516 }
517
518 Ok(EngineState::with_msg_max_size_capped(
519 usm.engine_id,
520 usm.engine_boots,
521 usm.engine_time,
522 reported_msg_max_size,
523 session_max,
524 ))
525}
526
527/// Returns true if `pdu` is a Report PDU containing a varbind with the given OID.
528fn pdu_has_report_oid(pdu: &crate::pdu::Pdu, expected_oid: &crate::Oid) -> bool {
529 use crate::pdu::PduType;
530 pdu.pdu_type == PduType::Report && pdu.varbinds.iter().any(|vb| &vb.oid == expected_oid)
531}
532
533/// Check if a Report PDU indicates "unknown engine ID" (discovery response).
534///
535/// Returns true if the PDU contains usmStatsUnknownEngineIDs varbind.
536#[must_use]
537pub fn is_unknown_engine_id_report(pdu: &crate::pdu::Pdu) -> bool {
538 pdu_has_report_oid(pdu, &report_oids::unknown_engine_ids())
539}
540
541/// Check if a Report PDU indicates "not in time window".
542///
543/// Returns true if the PDU contains usmStatsNotInTimeWindows varbind.
544#[must_use]
545pub fn is_not_in_time_window_report(pdu: &crate::pdu::Pdu) -> bool {
546 pdu_has_report_oid(pdu, &report_oids::not_in_time_windows())
547}
548
549/// Check if a Report PDU indicates "wrong digest" (authentication failure).
550///
551/// Returns true if the PDU contains usmStatsWrongDigests varbind.
552#[must_use]
553pub fn is_wrong_digest_report(pdu: &crate::pdu::Pdu) -> bool {
554 pdu_has_report_oid(pdu, &report_oids::wrong_digests())
555}
556
557/// Check if a Report PDU indicates "unsupported security level".
558///
559/// Returns true if the PDU contains usmStatsUnsupportedSecLevels varbind.
560#[must_use]
561pub fn is_unsupported_sec_level_report(pdu: &crate::pdu::Pdu) -> bool {
562 pdu_has_report_oid(pdu, &report_oids::unsupported_sec_levels())
563}
564
565/// Check if a Report PDU indicates "unknown user name".
566///
567/// Returns true if the PDU contains usmStatsUnknownUserNames varbind.
568#[must_use]
569pub fn is_unknown_user_name_report(pdu: &crate::pdu::Pdu) -> bool {
570 pdu_has_report_oid(pdu, &report_oids::unknown_user_names())
571}
572
573/// Check if a Report PDU indicates "decryption error".
574///
575/// Returns true if the PDU contains usmStatsDecryptionErrors varbind.
576#[must_use]
577pub fn is_decryption_error_report(pdu: &crate::pdu::Pdu) -> bool {
578 pdu_has_report_oid(pdu, &report_oids::decryption_errors())
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn test_engine_state_estimated_time() {
587 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
588
589 // Estimated time should be at least engine_time
590 let estimated = state.estimated_time();
591 assert!(estimated >= 1000);
592 }
593
594 #[test]
595 fn test_engine_state_update_time() {
596 let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
597
598 // Same boots, newer time -> should update
599 assert!(state.update_time(1, 1100));
600 assert_eq!(state.latest_received_engine_time, 1100);
601
602 // Same boots, older time -> should NOT update
603 assert!(!state.update_time(1, 1050));
604 assert_eq!(state.latest_received_engine_time, 1100);
605
606 // New boot cycle -> should update
607 assert!(state.update_time(2, 500));
608 assert_eq!(state.engine_boots, 2);
609 assert_eq!(state.latest_received_engine_time, 500);
610 }
611
612 /// Test anti-replay protection via latestReceivedEngineTime (RFC 3414 Section 3.2 Step 7b).
613 ///
614 /// The anti-replay mechanism rejects messages with engine time values that are
615 /// not newer than the latest received time. This prevents replay attacks where
616 /// an attacker captures and re-sends old authenticated messages.
617 #[test]
618 fn test_anti_replay_rejects_old_time() {
619 let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
620 state.latest_received_engine_time = 1500; // Simulate having received up to time 1500
621
622 // Attempt to replay a message from time 1400 (before latest)
623 // update_time returns false, indicating the update was rejected
624 assert!(
625 !state.update_time(1, 1400),
626 "Should reject replay: time 1400 < latest 1500"
627 );
628 assert_eq!(
629 state.latest_received_engine_time, 1500,
630 "Latest should not change"
631 );
632
633 // Even time 1500 (equal) should be rejected - must be strictly greater
634 assert!(
635 !state.update_time(1, 1500),
636 "Should reject replay: time 1500 == latest 1500"
637 );
638 assert_eq!(state.latest_received_engine_time, 1500);
639
640 // Time 1501 (newer) should be accepted
641 assert!(
642 state.update_time(1, 1501),
643 "Should accept: time 1501 > latest 1500"
644 );
645 assert_eq!(state.latest_received_engine_time, 1501);
646 }
647
648 /// Test anti-replay across boot cycles.
649 ///
650 /// A new boot cycle (higher boots value) always resets the `latest_received_engine_time`
651 /// since the agent has rebooted and time values are relative to the boot.
652 #[test]
653 fn test_anti_replay_new_boot_cycle_resets() {
654 let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
655 state.latest_received_engine_time = 5000; // High value from long uptime
656
657 // New boot cycle with lower time value - should accept
658 // because the engine rebooted (boots increased)
659 assert!(
660 state.update_time(2, 100),
661 "New boot cycle should accept even with lower time"
662 );
663 assert_eq!(state.engine_boots, 2);
664 assert_eq!(state.engine_time, 100);
665 assert_eq!(
666 state.latest_received_engine_time, 100,
667 "Latest should reset to new time"
668 );
669
670 // Now subsequent updates in the new boot cycle follow normal rules
671 assert!(
672 !state.update_time(2, 50),
673 "Should reject older time in same boot cycle"
674 );
675 assert!(state.update_time(2, 150), "Should accept newer time");
676 assert_eq!(state.latest_received_engine_time, 150);
677 }
678
679 /// Test anti-replay rejects old boot cycles.
680 ///
681 /// An attacker cannot replay messages from a previous boot cycle.
682 #[test]
683 fn test_anti_replay_rejects_old_boot_cycle() {
684 let mut state = EngineState::new(Bytes::from_static(b"engine"), 5, 1000);
685 state.latest_received_engine_time = 1000;
686
687 // Attempt to use old boot cycle (boots=4) - should reject
688 assert!(
689 !state.update_time(4, 9999),
690 "Should reject old boot cycle even with high time"
691 );
692 assert_eq!(state.engine_boots, 5, "Boots should not change");
693 assert_eq!(
694 state.latest_received_engine_time, 1000,
695 "Latest should not change"
696 );
697
698 // Attempt boots=0 - should reject
699 assert!(!state.update_time(0, 9999), "Should reject boots=0 replay");
700 }
701
702 /// Test anti-replay with exact boundary values.
703 #[test]
704 fn test_anti_replay_boundary_values() {
705 let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 0);
706
707 // Start with time=0
708 assert_eq!(state.latest_received_engine_time, 0);
709
710 // Time=1 should be accepted (> 0)
711 assert!(state.update_time(1, 1));
712 assert_eq!(state.latest_received_engine_time, 1);
713
714 // Time=0 should be rejected (< 1)
715 assert!(!state.update_time(1, 0));
716
717 // Large time value should work
718 assert!(state.update_time(1, u32::MAX - 1));
719 assert_eq!(state.latest_received_engine_time, u32::MAX - 1);
720
721 // u32::MAX should still work
722 assert!(state.update_time(1, u32::MAX));
723 assert_eq!(state.latest_received_engine_time, u32::MAX);
724
725 // Nothing can be newer than u32::MAX in the same boot cycle
726 assert!(!state.update_time(1, u32::MAX));
727 }
728
729 #[test]
730 fn test_engine_state_time_window() {
731 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
732
733 // Same boots, within window
734 assert!(state.is_in_time_window(1, 1000));
735 assert!(state.is_in_time_window(1, 1100)); // +100s
736 assert!(state.is_in_time_window(1, 900)); // -100s
737
738 // Different boots -> out of window
739 assert!(!state.is_in_time_window(2, 1000));
740 assert!(!state.is_in_time_window(0, 1000));
741
742 // Way outside time window
743 assert!(!state.is_in_time_window(1, 2000)); // +1000s > 150s
744 }
745
746 /// Test the exact 150-second time window boundary per RFC 3414 Section 2.2.3.
747 ///
748 /// The time window is exactly 150 seconds. Messages with time difference
749 /// of exactly 150 seconds should be accepted, but 151 seconds should fail.
750 #[test]
751 fn test_time_window_150s_exact_boundary() {
752 // Use high engine_time to avoid underflow complications
753 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 10000);
754
755 // At exactly +150 seconds from engine_time (10000 + 150 = 10150)
756 // The is_in_time_window compares against estimated_time(), which adds
757 // elapsed time. For a fresh EngineState, elapsed should be ~0.
758 // So msg_time of 10150 should be within window (diff = 150 <= TIME_WINDOW)
759 assert!(
760 state.is_in_time_window(1, 10150),
761 "Message at exactly +150s boundary should be in window"
762 );
763
764 // At exactly +151 seconds (diff = 151 > TIME_WINDOW = 150)
765 assert!(
766 !state.is_in_time_window(1, 10151),
767 "Message at +151s should be outside window"
768 );
769
770 // At exactly -150 seconds (10000 - 150 = 9850)
771 assert!(
772 state.is_in_time_window(1, 9850),
773 "Message at exactly -150s boundary should be in window"
774 );
775
776 // At exactly -151 seconds (10000 - 151 = 9849)
777 assert!(
778 !state.is_in_time_window(1, 9849),
779 "Message at -151s should be outside window"
780 );
781 }
782
783 /// Test time window with maximum engine boots value (2_147_483_647).
784 ///
785 /// Per RFC 3414 Section 2.2.3, when snmpEngineBoots is 2_147_483_647 (latched),
786 /// all messages should be rejected as outside the time window.
787 #[test]
788 fn test_time_window_boots_latched() {
789 // Maximum boots value indicates the engine has been rebooted too many times
790 // and should reject all authenticated messages
791 let state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_647, 1000);
792
793 // Even with matching boots and same time, should fail when latched
794 assert!(
795 !state.is_in_time_window(2_147_483_647, 1000),
796 "Latched boots should reject all messages"
797 );
798
799 // Any other time should also fail
800 assert!(!state.is_in_time_window(2_147_483_647, 1100));
801 assert!(!state.is_in_time_window(2_147_483_647, 900));
802 }
803
804 /// Test time window edge cases with boot counter differences.
805 ///
806 /// Boot counter must match exactly; any difference means out of window.
807 #[test]
808 fn test_time_window_boots_mismatch() {
809 let state = EngineState::new(Bytes::from_static(b"engine"), 100, 1000);
810
811 // Boots too high
812 assert!(!state.is_in_time_window(101, 1000));
813 assert!(!state.is_in_time_window(200, 1000));
814
815 // Boots too low (replay from previous boot cycle)
816 assert!(!state.is_in_time_window(99, 1000));
817 assert!(!state.is_in_time_window(0, 1000));
818 }
819
820 /// Non-authoritative timeliness (RFC 3414 Section 3.2 Step 7b): a message
821 /// with time within the window is accepted without updating the LCD.
822 #[test]
823 fn test_check_and_update_timeliness_within_window_accepted() {
824 let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
825
826 // Older time but within 150s of our notion: accepted, latest unchanged
827 assert!(state.check_and_update_timeliness(3, 900));
828 assert_eq!(state.latest_received_engine_time, 1000);
829
830 // Exactly at the boundary (1000 - 150 = 850): accepted
831 assert!(state.check_and_update_timeliness(3, 850));
832 }
833
834 #[test]
835 fn test_check_and_update_timeliness_newer_time_updates_lcd() {
836 let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
837
838 assert!(state.check_and_update_timeliness(3, 1200));
839 assert_eq!(state.latest_received_engine_time, 1200);
840 assert_eq!(state.engine_time, 1200);
841 }
842
843 #[test]
844 fn test_check_and_update_timeliness_stale_time_rejected() {
845 let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
846
847 // 500 < 1000 - 150: replayed/stale message
848 assert!(!state.check_and_update_timeliness(3, 500));
849 // Just past the boundary
850 assert!(!state.check_and_update_timeliness(3, 849));
851 }
852
853 #[test]
854 fn test_check_and_update_timeliness_old_boots_rejected() {
855 let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
856
857 assert!(!state.check_and_update_timeliness(2, 5000));
858 assert_eq!(state.engine_boots, 3, "old boot cycle must not update LCD");
859 }
860
861 #[test]
862 fn test_check_and_update_timeliness_reboot_accepted() {
863 let mut state = EngineState::new(Bytes::from_static(b"engine"), 3, 1000);
864
865 // Sender rebooted: higher boots with low time is accepted and updates LCD
866 assert!(state.check_and_update_timeliness(4, 10));
867 assert_eq!(state.engine_boots, 4);
868 assert_eq!(state.latest_received_engine_time, 10);
869
870 // Messages from the previous boot cycle are now rejected
871 assert!(!state.check_and_update_timeliness(3, 99999));
872 }
873
874 #[test]
875 fn test_check_and_update_timeliness_latched_boots_rejected() {
876 let mut state = EngineState::new(Bytes::from_static(b"engine"), MAX_ENGINE_TIME, 1000);
877
878 assert!(!state.check_and_update_timeliness(MAX_ENGINE_TIME, 1000));
879 }
880
881 #[test]
882 fn test_engine_cache_basic_operations() {
883 let cache = EngineCache::new();
884 let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
885
886 // Initially empty
887 assert!(cache.is_empty());
888 assert!(cache.get(&addr).is_none());
889
890 // Insert
891 let state = EngineState::new(Bytes::from_static(b"engine1"), 1, 1000);
892 cache.insert(addr, state);
893
894 assert_eq!(cache.len(), 1);
895 assert!(!cache.is_empty());
896
897 // Get
898 let retrieved = cache.get(&addr).unwrap();
899 assert_eq!(retrieved.engine_id.as_ref(), b"engine1");
900 assert_eq!(retrieved.engine_boots, 1);
901
902 // Update time
903 assert!(cache.update_time(&addr, 1, 1100));
904
905 // Remove
906 let removed = cache.remove(&addr).unwrap();
907 assert_eq!(removed.latest_received_engine_time, 1100);
908 assert!(cache.is_empty());
909 }
910
911 #[test]
912 fn test_engine_cache_ttl_expiry() {
913 let cache = EngineCache::new().with_ttl(Duration::from_millis(50));
914 let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
915
916 let state = EngineState::new(Bytes::from_static(b"engine1"), 1, 1000);
917 cache.insert(addr, state);
918 assert!(cache.get(&addr).is_some());
919
920 // Wait well past TTL to avoid flakiness on slow CI
921 std::thread::sleep(Duration::from_millis(200));
922 assert!(
923 cache.get(&addr).is_none(),
924 "expired entry should return None"
925 );
926 assert!(cache.is_empty(), "expired entry should be removed");
927 }
928
929 #[test]
930 fn test_engine_cache_ttl_refresh_on_time_update() {
931 let cache = EngineCache::new().with_ttl(Duration::from_millis(500));
932 let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
933
934 let state = EngineState::new(Bytes::from_static(b"engine1"), 1, 1000);
935 cache.insert(addr, state);
936
937 // Wait partway, then refresh via update_time
938 std::thread::sleep(Duration::from_millis(300));
939 assert!(cache.update_time(&addr, 1, 1050));
940
941 // Wait again - would have expired without the refresh
942 std::thread::sleep(Duration::from_millis(300));
943 assert!(
944 cache.get(&addr).is_some(),
945 "refreshed entry should still be alive"
946 );
947 }
948
949 #[test]
950 fn test_engine_cache_max_capacity_eviction() {
951 let cache = EngineCache::new().with_max_capacity(2);
952 let addr1: SocketAddr = "192.168.1.1:161".parse().unwrap();
953 let addr2: SocketAddr = "192.168.1.2:161".parse().unwrap();
954 let addr3: SocketAddr = "192.168.1.3:161".parse().unwrap();
955
956 cache.insert(addr1, EngineState::new(Bytes::from_static(b"e1"), 1, 100));
957 std::thread::sleep(Duration::from_millis(10));
958 cache.insert(addr2, EngineState::new(Bytes::from_static(b"e2"), 1, 200));
959 std::thread::sleep(Duration::from_millis(10));
960
961 assert_eq!(cache.len(), 2);
962
963 // Third insert should evict addr1 (oldest synced_at)
964 cache.insert(addr3, EngineState::new(Bytes::from_static(b"e3"), 1, 300));
965 assert_eq!(cache.len(), 2);
966 assert!(
967 cache.get(&addr1).is_none(),
968 "oldest entry should be evicted"
969 );
970 assert!(cache.get(&addr2).is_some());
971 assert!(cache.get(&addr3).is_some());
972 }
973
974 #[test]
975 fn test_parse_discovery_response() {
976 let usm = UsmSecurityParams::new(b"test-engine-id".as_slice(), 42, 12345, b"".as_slice());
977 let encoded = usm.encode();
978
979 let state = parse_discovery_response(&encoded).unwrap();
980 assert_eq!(state.engine_id.as_ref(), b"test-engine-id");
981 assert_eq!(state.engine_boots, 42);
982 assert_eq!(state.engine_time, 12345);
983 }
984
985 #[test]
986 fn test_parse_discovery_response_empty_engine_id() {
987 let usm = UsmSecurityParams::empty();
988 let encoded = usm.encode();
989
990 let result = parse_discovery_response(&encoded);
991 assert!(matches!(
992 *result.unwrap_err(),
993 Error::MalformedResponse { .. }
994 ));
995 }
996
997 #[test]
998 fn test_is_unknown_engine_id_report() {
999 use crate::Value;
1000 use crate::VarBind;
1001 use crate::pdu::{Pdu, PduType};
1002
1003 // Report with usmStatsUnknownEngineIDs
1004 let mut pdu = Pdu {
1005 pdu_type: PduType::Report,
1006 request_id: 1,
1007 error_status: 0,
1008 error_index: 0,
1009 varbinds: vec![VarBind {
1010 oid: report_oids::unknown_engine_ids(),
1011 value: Value::Counter32(1),
1012 }],
1013 };
1014
1015 assert!(is_unknown_engine_id_report(&pdu));
1016
1017 // Different report type
1018 pdu.varbinds[0].oid = report_oids::not_in_time_windows();
1019 assert!(!is_unknown_engine_id_report(&pdu));
1020
1021 // Not a Report PDU
1022 pdu.pdu_type = PduType::Response;
1023 assert!(!is_unknown_engine_id_report(&pdu));
1024 }
1025
1026 // ========================================================================
1027 // Engine Boots Overflow Tests (RFC 3414 Section 2.2.3)
1028 // ========================================================================
1029
1030 /// Test that `update_time` accepts transition to maximum boots value.
1031 ///
1032 /// When the engine reboots and boots reaches 2_147_483_647 (`i32::MAX`),
1033 /// the update should be accepted since it's a valid new boot cycle.
1034 #[test]
1035 fn test_engine_boots_transition_to_max() {
1036 let mut state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_646, 1000);
1037
1038 // Boot cycle to max value should be accepted
1039 assert!(
1040 state.update_time(2_147_483_647, 100),
1041 "Transition to boots=2_147_483_647 should be accepted"
1042 );
1043 assert_eq!(state.engine_boots, 2_147_483_647);
1044 assert_eq!(state.engine_time, 100);
1045 }
1046
1047 /// Test `update_time` behavior when boots is latched.
1048 ///
1049 /// The `update_time` function still tracks received times for anti-replay
1050 /// purposes. The security rejection happens in `is_in_time_window()`.
1051 /// However, when boots=2_147_483_647, there's no valid "higher" boots value,
1052 /// so boot cycle transitions are impossible.
1053 #[test]
1054 fn test_engine_boots_latched_update_behavior() {
1055 let mut state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_647, 1000);
1056
1057 // Time tracking still works for same boots
1058 assert!(
1059 state.update_time(2_147_483_647, 2000),
1060 "Time tracking updates should still work"
1061 );
1062 assert_eq!(state.latest_received_engine_time, 2000);
1063
1064 // Old time rejected per normal anti-replay
1065 assert!(!state.update_time(2_147_483_647, 1500));
1066 assert_eq!(state.latest_received_engine_time, 2000);
1067
1068 // The key security check is in is_in_time_window
1069 assert!(
1070 !state.is_in_time_window(2_147_483_647, 2000),
1071 "Latched state should still reject all messages"
1072 );
1073 }
1074
1075 /// Test that time window rejects all messages when boots is latched.
1076 ///
1077 /// This is the key security property: once an engine's boots counter
1078 /// reaches its maximum value, all authenticated messages should be
1079 /// rejected to prevent replay attacks.
1080 #[test]
1081 fn test_engine_boots_latched_time_window_always_fails() {
1082 let state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_647, 1000);
1083
1084 // All time values should fail when latched
1085 assert!(!state.is_in_time_window(2_147_483_647, 0));
1086 assert!(!state.is_in_time_window(2_147_483_647, 1000));
1087 assert!(!state.is_in_time_window(2_147_483_647, 1001));
1088 assert!(!state.is_in_time_window(2_147_483_647, u32::MAX));
1089
1090 // Even previous boots values should fail
1091 assert!(!state.is_in_time_window(2_147_483_646, 1000));
1092 assert!(!state.is_in_time_window(0, 1000));
1093 }
1094
1095 /// Test creating `EngineState` directly with latched boots value.
1096 ///
1097 /// An agent that has been running for a very long time might already
1098 /// be in the latched state when we first discover it.
1099 #[test]
1100 fn test_engine_state_created_latched() {
1101 let state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_647, 5000);
1102
1103 assert_eq!(state.engine_boots, 2_147_483_647);
1104 assert_eq!(state.engine_time, 5000);
1105 assert_eq!(state.latest_received_engine_time, 5000);
1106
1107 // Should immediately be in latched state
1108 assert!(
1109 !state.is_in_time_window(2_147_483_647, 5000),
1110 "Newly created latched engine should reject all messages"
1111 );
1112 }
1113
1114 /// Test that boots values near the maximum work correctly.
1115 ///
1116 /// Verify normal operation just before reaching the latch point.
1117 #[test]
1118 fn test_engine_boots_near_max_operates_normally() {
1119 let mut state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_645, 1000);
1120
1121 // Normal time window checks should work
1122 assert!(state.is_in_time_window(2_147_483_645, 1000));
1123 assert!(state.is_in_time_window(2_147_483_645, 1100));
1124 assert!(!state.is_in_time_window(2_147_483_645, 1200)); // Outside 150s window
1125
1126 // Should accept boot to 2_147_483_646
1127 assert!(state.update_time(2_147_483_646, 500));
1128 assert_eq!(state.engine_boots, 2_147_483_646);
1129 assert!(state.is_in_time_window(2_147_483_646, 500));
1130
1131 // Should accept boot to 2_147_483_647 (becomes latched)
1132 assert!(state.update_time(2_147_483_647, 100));
1133 assert_eq!(state.engine_boots, 2_147_483_647);
1134
1135 // Now latched - all messages rejected
1136 assert!(!state.is_in_time_window(2_147_483_647, 100));
1137 }
1138
1139 /// Test that `update_time` correctly handles the comparison when
1140 /// current boots is high but not yet latched.
1141 #[test]
1142 fn test_engine_boots_high_value_update_logic() {
1143 let mut state = EngineState::new(Bytes::from_static(b"engine"), 2_147_483_640, 1000);
1144
1145 // Old boot cycles should be rejected
1146 assert!(!state.update_time(2147483639, 9999));
1147 assert!(!state.update_time(0, 9999));
1148
1149 // Same boot, older time should be rejected
1150 assert!(!state.update_time(2_147_483_640, 500));
1151
1152 // Same boot, newer time should be accepted
1153 assert!(state.update_time(2_147_483_640, 1500));
1154 assert_eq!(state.latest_received_engine_time, 1500);
1155
1156 // New boot should be accepted
1157 assert!(state.update_time(2_147_483_641, 100));
1158 assert_eq!(state.engine_boots, 2_147_483_641);
1159 }
1160
1161 /// Test `EngineCache` behavior with latched engines.
1162 ///
1163 /// Even when latched, time tracking updates are accepted (for anti-replay).
1164 /// The security rejection is enforced by `is_in_time_window()`, not `update_time()`.
1165 #[test]
1166 fn test_engine_cache_latched_engine() {
1167 let cache = EngineCache::new();
1168 let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
1169
1170 // Insert latched engine
1171 cache.insert(
1172 addr,
1173 EngineState::new(Bytes::from_static(b"latched"), 2_147_483_647, 1000),
1174 );
1175
1176 // Time tracking still works
1177 assert!(
1178 cache.update_time(&addr, 2_147_483_647, 2000),
1179 "Time tracking should update even for latched engine"
1180 );
1181
1182 // Verify state was updated
1183 let state = cache.get(&addr).unwrap();
1184 assert_eq!(state.latest_received_engine_time, 2000);
1185
1186 // But the key security property: is_in_time_window rejects
1187 assert!(
1188 !state.is_in_time_window(2_147_483_647, 2000),
1189 "Latched engine should reject all time window checks"
1190 );
1191 }
1192
1193 // ========================================================================
1194 // msgMaxSize Capping Tests
1195 // ========================================================================
1196 //
1197 // Per net-snmp behavior, agent-reported msgMaxSize values should be capped
1198 // to the session's maximum to prevent buffer issues with non-compliant agents.
1199
1200 /// Test that `EngineState` stores the agent's advertised msgMaxSize.
1201 ///
1202 /// The `msg_max_size` field tracks the maximum message size the remote engine
1203 /// can accept, as reported in `SNMPv3` message headers.
1204 #[test]
1205 fn test_engine_state_stores_msg_max_size() {
1206 let state = EngineState::with_msg_max_size(Bytes::from_static(b"engine"), 1, 1000, 65507);
1207 assert_eq!(state.msg_max_size, 65507);
1208 }
1209
1210 /// Test that the default constructor uses the maximum UDP message size.
1211 ///
1212 /// When msgMaxSize is not provided (e.g., during basic discovery),
1213 /// default to the maximum safe UDP datagram size (65507 bytes).
1214 #[test]
1215 fn test_engine_state_default_msg_max_size() {
1216 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
1217 assert_eq!(
1218 state.msg_max_size, DEFAULT_MSG_MAX_SIZE,
1219 "Default msg_max_size should be the maximum UDP datagram size"
1220 );
1221 }
1222
1223 /// Test that msgMaxSize is capped to session maximum.
1224 ///
1225 /// Non-compliant agents may advertise msgMaxSize values larger than they
1226 /// (or we) can actually handle. Values exceeding the session maximum are
1227 /// silently capped to prevent buffer issues.
1228 #[test]
1229 fn test_engine_state_msg_max_size_capped_to_session_max() {
1230 // Agent advertises 2GB, but we cap to 65507 (our session max)
1231 let state = EngineState::with_msg_max_size_capped(
1232 Bytes::from_static(b"engine"),
1233 1,
1234 1000,
1235 2_000_000_000, // Agent claims 2GB
1236 65507, // Our session maximum
1237 );
1238 assert_eq!(
1239 state.msg_max_size, 65507,
1240 "msg_max_size should be capped to session maximum"
1241 );
1242 }
1243
1244 /// Test that msgMaxSize within session maximum is not modified.
1245 ///
1246 /// When the agent advertises a reasonable value below our maximum,
1247 /// it should be stored as-is without capping.
1248 #[test]
1249 fn test_engine_state_msg_max_size_within_limit_not_capped() {
1250 let state = EngineState::with_msg_max_size_capped(
1251 Bytes::from_static(b"engine"),
1252 1,
1253 1000,
1254 1472, // Agent claims 1472 (Ethernet MTU - headers)
1255 65507, // Our session maximum
1256 );
1257 assert_eq!(
1258 state.msg_max_size, 1472,
1259 "msg_max_size within limit should not be capped"
1260 );
1261 }
1262
1263 /// Test msgMaxSize capping at exact boundary.
1264 ///
1265 /// When agent's msgMaxSize exactly equals session maximum, no capping occurs.
1266 #[test]
1267 fn test_engine_state_msg_max_size_at_exact_boundary() {
1268 let state = EngineState::with_msg_max_size_capped(
1269 Bytes::from_static(b"engine"),
1270 1,
1271 1000,
1272 65507, // Exactly at session max
1273 65507, // Our session maximum
1274 );
1275 assert_eq!(state.msg_max_size, 65507);
1276 }
1277
1278 /// Test msgMaxSize capping with TCP transport maximum.
1279 ///
1280 /// TCP transports may have higher limits. Verify capping works with
1281 /// the larger TCP message size limit.
1282 #[test]
1283 fn test_engine_state_msg_max_size_tcp_limit() {
1284 const TCP_MAX: u32 = 0x7FFF_FFFF; // net-snmp TCP maximum
1285
1286 // Agent claims i32::MAX, we have same limit
1287 let state = EngineState::with_msg_max_size_capped(
1288 Bytes::from_static(b"engine"),
1289 1,
1290 1000,
1291 TCP_MAX,
1292 TCP_MAX,
1293 );
1294 assert_eq!(state.msg_max_size, TCP_MAX);
1295
1296 // Agent claims more than i32::MAX (wrapped negative), cap to limit
1297 let state = EngineState::with_msg_max_size_capped(
1298 Bytes::from_static(b"engine"),
1299 1,
1300 1000,
1301 u32::MAX, // Larger than any valid msgMaxSize
1302 TCP_MAX,
1303 );
1304 assert_eq!(
1305 state.msg_max_size, TCP_MAX,
1306 "Values exceeding session max should be capped"
1307 );
1308 }
1309
1310 /// Test that `EngineState::new` uses the default `msg_max_size` constant.
1311 #[test]
1312 fn test_engine_state_new_uses_default_constant() {
1313 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
1314
1315 // DEFAULT_MSG_MAX_SIZE is the maximum UDP payload (65507)
1316 assert_eq!(state.msg_max_size, DEFAULT_MSG_MAX_SIZE);
1317 }
1318
1319 // ========================================================================
1320 // Engine Time Overflow Tests (RFC 3414 Section 2.2.1)
1321 // ========================================================================
1322 //
1323 // Per RFC 3414, snmpEngineTime is a 31-bit value (0..2_147_483_647).
1324 // When the time value would exceed this, it must not go beyond MAX_ENGINE_TIME.
1325
1326 /// Test that `estimated_time` caps at `MAX_ENGINE_TIME` (2^31-1).
1327 ///
1328 /// Per RFC 3414 Section 2.2.1, snmpEngineTime is 31-bit (0..2_147_483_647).
1329 /// If time would exceed this value, it should cap at `MAX_ENGINE_TIME` rather
1330 /// than continuing to `u32::MAX`.
1331 #[test]
1332 fn test_estimated_time_caps_at_max_engine_time() {
1333 // Create state with engine_time near the maximum
1334 let state = EngineState::new(Bytes::from_static(b"engine"), 1, MAX_ENGINE_TIME - 10);
1335
1336 // Even though we're adding elapsed time, result should never exceed MAX_ENGINE_TIME
1337 let estimated = state.estimated_time();
1338 assert!(
1339 estimated <= MAX_ENGINE_TIME,
1340 "estimated_time() should never exceed MAX_ENGINE_TIME ({MAX_ENGINE_TIME}), got {estimated}"
1341 );
1342 }
1343
1344 /// Test that `estimated_time` at `MAX_ENGINE_TIME` stays at `MAX_ENGINE_TIME`.
1345 ///
1346 /// When `engine_time` is already at the maximum, adding more elapsed time
1347 /// should not increase it further.
1348 #[test]
1349 fn test_estimated_time_at_max_stays_at_max() {
1350 let state = EngineState::new(Bytes::from_static(b"engine"), 1, MAX_ENGINE_TIME);
1351
1352 // Should stay at MAX_ENGINE_TIME
1353 let estimated = state.estimated_time();
1354 assert_eq!(
1355 estimated, MAX_ENGINE_TIME,
1356 "estimated_time() at max should stay at MAX_ENGINE_TIME"
1357 );
1358 }
1359
1360 /// Test that `engine_time` values beyond `MAX_ENGINE_TIME` are invalid.
1361 ///
1362 /// This verifies the constant value is correct per RFC 3414.
1363 #[test]
1364 fn test_max_engine_time_constant() {
1365 // RFC 3414 specifies 31-bit (0..2_147_483_647), which is i32::MAX
1366 assert_eq!(MAX_ENGINE_TIME, 2_147_483_647);
1367 assert_eq!(MAX_ENGINE_TIME, i32::MAX as u32);
1368 }
1369
1370 /// Test that normal time estimation works below `MAX_ENGINE_TIME`.
1371 ///
1372 /// For typical time values well below the maximum, estimation should
1373 /// work normally without artificial capping.
1374 #[test]
1375 fn test_estimated_time_normal_operation() {
1376 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
1377
1378 // For a fresh state, elapsed should be ~0, so estimated should be ~engine_time
1379 let estimated = state.estimated_time();
1380 assert!(
1381 estimated >= 1000,
1382 "estimated_time() should be at least engine_time"
1383 );
1384 // Should not hit the cap
1385 assert!(
1386 estimated < MAX_ENGINE_TIME,
1387 "Normal time values should not hit MAX_ENGINE_TIME cap"
1388 );
1389 }
1390}