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