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::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..2147483647).
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 = 2147483647;
46
47/// Default msgMaxSize for UDP transport (65535 - 20 IPv4 - 8 UDP = 65507).
48pub const DEFAULT_MSG_MAX_SIZE: u32 = 65507;
49
50/// USM statistics OIDs used in Report PDUs.
51pub mod report_oids {
52 use crate::Oid;
53 use crate::oid;
54
55 /// 1.3.6.1.6.3.15.1.1.1.0 - usmStatsUnsupportedSecLevels
56 pub fn unsupported_sec_levels() -> Oid {
57 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 1, 0)
58 }
59
60 /// 1.3.6.1.6.3.15.1.1.2.0 - usmStatsNotInTimeWindows
61 pub fn not_in_time_windows() -> Oid {
62 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 2, 0)
63 }
64
65 /// 1.3.6.1.6.3.15.1.1.3.0 - usmStatsUnknownUserNames
66 pub fn unknown_user_names() -> Oid {
67 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 3, 0)
68 }
69
70 /// 1.3.6.1.6.3.15.1.1.4.0 - usmStatsUnknownEngineIDs
71 pub fn unknown_engine_ids() -> Oid {
72 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 4, 0)
73 }
74
75 /// 1.3.6.1.6.3.15.1.1.5.0 - usmStatsWrongDigests
76 pub fn wrong_digests() -> Oid {
77 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 5, 0)
78 }
79
80 /// 1.3.6.1.6.3.15.1.1.6.0 - usmStatsDecryptionErrors
81 pub fn decryption_errors() -> Oid {
82 oid!(1, 3, 6, 1, 6, 3, 15, 1, 1, 6, 0)
83 }
84}
85
86/// Discovered engine state.
87#[derive(Debug, Clone)]
88pub struct EngineState {
89 /// Authoritative engine ID
90 pub engine_id: Bytes,
91 /// Engine boot count
92 pub engine_boots: u32,
93 /// Engine time at last sync
94 pub engine_time: u32,
95 /// Local time when engine_time was received
96 pub synced_at: Instant,
97 /// Latest received engine time (for anti-replay, RFC 3414 Section 2.3)
98 pub latest_received_engine_time: u32,
99 /// Maximum message size the remote engine can accept (from msgMaxSize header).
100 pub msg_max_size: u32,
101}
102
103impl EngineState {
104 /// Create new engine state from discovery response.
105 pub fn new(engine_id: Bytes, engine_boots: u32, engine_time: u32) -> Self {
106 Self {
107 engine_id,
108 engine_boots,
109 engine_time,
110 synced_at: Instant::now(),
111 latest_received_engine_time: engine_time,
112 msg_max_size: DEFAULT_MSG_MAX_SIZE,
113 }
114 }
115
116 /// Create with explicit msgMaxSize from agent's header.
117 pub fn with_msg_max_size(
118 engine_id: Bytes,
119 engine_boots: u32,
120 engine_time: u32,
121 msg_max_size: u32,
122 ) -> Self {
123 Self {
124 engine_id,
125 engine_boots,
126 engine_time,
127 synced_at: Instant::now(),
128 latest_received_engine_time: engine_time,
129 msg_max_size,
130 }
131 }
132
133 /// Create with msgMaxSize capped to session maximum.
134 ///
135 /// Non-compliant agents may advertise msgMaxSize values larger than they
136 /// can handle. This caps the value to a known safe session limit.
137 pub fn with_msg_max_size_capped(
138 engine_id: Bytes,
139 engine_boots: u32,
140 engine_time: u32,
141 reported_msg_max_size: u32,
142 session_max: u32,
143 ) -> Self {
144 let msg_max_size = if reported_msg_max_size > session_max {
145 tracing::debug!(target: "async_snmp::v3", { reported = reported_msg_max_size, session_max = session_max }, "capping msgMaxSize to session limit");
146 session_max
147 } else {
148 reported_msg_max_size
149 };
150
151 Self {
152 engine_id,
153 engine_boots,
154 engine_time,
155 synced_at: Instant::now(),
156 latest_received_engine_time: engine_time,
157 msg_max_size,
158 }
159 }
160
161 /// Get the estimated current engine time.
162 ///
163 /// This adds elapsed local time to the synced engine time.
164 /// Per RFC 3414 Section 2.2.1, the result is capped at MAX_ENGINE_TIME (2^31-1).
165 pub fn estimated_time(&self) -> u32 {
166 let elapsed = self.synced_at.elapsed().as_secs() as u32;
167 self.engine_time
168 .saturating_add(elapsed)
169 .min(MAX_ENGINE_TIME)
170 }
171
172 /// Update time from a response.
173 ///
174 /// Per RFC 3414 Section 3.2 Step 7b, only update if:
175 /// - Response boots > local boots, OR
176 /// - Response boots == local boots AND response time > latest_received_engine_time
177 pub fn update_time(&mut self, response_boots: u32, response_time: u32) -> bool {
178 if response_boots > self.engine_boots {
179 // New boot cycle
180 self.engine_boots = response_boots;
181 self.engine_time = response_time;
182 self.synced_at = Instant::now();
183 self.latest_received_engine_time = response_time;
184 true
185 } else if response_boots == self.engine_boots
186 && response_time > self.latest_received_engine_time
187 {
188 // Same boot cycle, newer time
189 self.engine_time = response_time;
190 self.synced_at = Instant::now();
191 self.latest_received_engine_time = response_time;
192 true
193 } else {
194 false
195 }
196 }
197
198 /// Check if a message time is within the time window.
199 ///
200 /// Per RFC 3414 Section 2.2.3, a message is outside the window if:
201 /// - Local boots is 2147483647 (latched), OR
202 /// - Message boots differs from local boots, OR
203 /// - |message_time - local_time| > 150 seconds
204 pub fn is_in_time_window(&self, msg_boots: u32, msg_time: u32) -> bool {
205 // Check for latched boots (max value)
206 if self.engine_boots == 2147483647 {
207 return false;
208 }
209
210 // Boots must match
211 if msg_boots != self.engine_boots {
212 return false;
213 }
214
215 // Time must be within window
216 let local_time = self.estimated_time();
217 let diff = msg_time.abs_diff(local_time);
218
219 diff <= TIME_WINDOW
220 }
221}
222
223/// Thread-safe cache of discovered engine state.
224///
225/// Use this to share engine discovery results across multiple clients
226/// targeting different engines.
227///
228/// # Example
229///
230/// ```ignore
231/// use std::sync::Arc;
232///
233/// let cache = Arc::new(EngineCache::new());
234///
235/// let client1 = Client::builder("192.168.1.1:161")
236/// .username("admin")
237/// .auth(AuthProtocol::Sha1, "authpass")
238/// .engine_cache(cache.clone())
239/// .connect()
240/// .await?;
241///
242/// let client2 = Client::builder("192.168.1.2:161")
243/// .username("admin")
244/// .auth(AuthProtocol::Sha1, "authpass")
245/// .engine_cache(cache.clone())
246/// .connect()
247/// .await?;
248/// ```
249#[derive(Debug, Default)]
250pub struct EngineCache {
251 engines: RwLock<HashMap<SocketAddr, EngineState>>,
252}
253
254impl EngineCache {
255 /// Create a new empty engine cache.
256 pub fn new() -> Self {
257 Self {
258 engines: RwLock::new(HashMap::new()),
259 }
260 }
261
262 /// Get cached engine state for a target.
263 pub fn get(&self, target: &SocketAddr) -> Option<EngineState> {
264 self.engines.read().ok()?.get(target).cloned()
265 }
266
267 /// Store engine state for a target.
268 pub fn insert(&self, target: SocketAddr, state: EngineState) {
269 if let Ok(mut engines) = self.engines.write() {
270 engines.insert(target, state);
271 }
272 }
273
274 /// Update time for an existing entry.
275 ///
276 /// Returns true if the entry was updated, false if not found or not updated.
277 pub fn update_time(
278 &self,
279 target: &SocketAddr,
280 response_boots: u32,
281 response_time: u32,
282 ) -> bool {
283 if let Ok(mut engines) = self.engines.write()
284 && let Some(state) = engines.get_mut(target)
285 {
286 return state.update_time(response_boots, response_time);
287 }
288 false
289 }
290
291 /// Remove cached state for a target.
292 pub fn remove(&self, target: &SocketAddr) -> Option<EngineState> {
293 self.engines.write().ok()?.remove(target)
294 }
295
296 /// Clear all cached state.
297 pub fn clear(&self) {
298 if let Ok(mut engines) = self.engines.write() {
299 engines.clear();
300 }
301 }
302
303 /// Get the number of cached engines.
304 pub fn len(&self) -> usize {
305 self.engines.read().map(|e| e.len()).unwrap_or(0)
306 }
307
308 /// Check if the cache is empty.
309 pub fn is_empty(&self) -> bool {
310 self.len() == 0
311 }
312}
313
314/// Extract engine state from a discovery response's USM security parameters.
315///
316/// The discovery response (Report PDU) contains the authoritative engine's
317/// ID, boots, and time in the USM security parameters field.
318pub fn parse_discovery_response(security_params: &Bytes) -> Result<EngineState> {
319 parse_discovery_response_with_limits(
320 security_params,
321 DEFAULT_MSG_MAX_SIZE,
322 DEFAULT_MSG_MAX_SIZE,
323 )
324}
325
326/// Extract engine state with explicit msgMaxSize and session limit.
327///
328/// The `reported_msg_max_size` comes from the V3 message header (MsgGlobalData).
329/// The `session_max` is our transport's maximum message size.
330/// Values are capped to prevent issues with non-compliant agents.
331pub fn parse_discovery_response_with_limits(
332 security_params: &Bytes,
333 reported_msg_max_size: u32,
334 session_max: u32,
335) -> Result<EngineState> {
336 let usm = UsmSecurityParams::decode(security_params.clone())?;
337
338 if usm.engine_id.is_empty() {
339 tracing::debug!(target: "async_snmp::engine", "discovery response contained empty engine ID");
340 return Err(Error::MalformedResponse {
341 target: SocketAddr::from(([0, 0, 0, 0], 0)),
342 }
343 .boxed());
344 }
345
346 Ok(EngineState::with_msg_max_size_capped(
347 usm.engine_id,
348 usm.engine_boots,
349 usm.engine_time,
350 reported_msg_max_size,
351 session_max,
352 ))
353}
354
355/// Returns true if `pdu` is a Report PDU containing a varbind with the given OID.
356fn pdu_has_report_oid(pdu: &crate::pdu::Pdu, expected_oid: &crate::Oid) -> bool {
357 use crate::pdu::PduType;
358 pdu.pdu_type == PduType::Report && pdu.varbinds.iter().any(|vb| &vb.oid == expected_oid)
359}
360
361/// Check if a Report PDU indicates "unknown engine ID" (discovery response).
362///
363/// Returns true if the PDU contains usmStatsUnknownEngineIDs varbind.
364pub fn is_unknown_engine_id_report(pdu: &crate::pdu::Pdu) -> bool {
365 pdu_has_report_oid(pdu, &report_oids::unknown_engine_ids())
366}
367
368/// Check if a Report PDU indicates "not in time window".
369///
370/// Returns true if the PDU contains usmStatsNotInTimeWindows varbind.
371pub fn is_not_in_time_window_report(pdu: &crate::pdu::Pdu) -> bool {
372 pdu_has_report_oid(pdu, &report_oids::not_in_time_windows())
373}
374
375/// Check if a Report PDU indicates "wrong digest" (authentication failure).
376///
377/// Returns true if the PDU contains usmStatsWrongDigests varbind.
378pub fn is_wrong_digest_report(pdu: &crate::pdu::Pdu) -> bool {
379 pdu_has_report_oid(pdu, &report_oids::wrong_digests())
380}
381
382/// Check if a Report PDU indicates "unsupported security level".
383///
384/// Returns true if the PDU contains usmStatsUnsupportedSecLevels varbind.
385pub fn is_unsupported_sec_level_report(pdu: &crate::pdu::Pdu) -> bool {
386 pdu_has_report_oid(pdu, &report_oids::unsupported_sec_levels())
387}
388
389/// Check if a Report PDU indicates "unknown user name".
390///
391/// Returns true if the PDU contains usmStatsUnknownUserNames varbind.
392pub fn is_unknown_user_name_report(pdu: &crate::pdu::Pdu) -> bool {
393 pdu_has_report_oid(pdu, &report_oids::unknown_user_names())
394}
395
396/// Check if a Report PDU indicates "decryption error".
397///
398/// Returns true if the PDU contains usmStatsDecryptionErrors varbind.
399pub fn is_decryption_error_report(pdu: &crate::pdu::Pdu) -> bool {
400 pdu_has_report_oid(pdu, &report_oids::decryption_errors())
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_engine_state_estimated_time() {
409 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
410
411 // Estimated time should be at least engine_time
412 let estimated = state.estimated_time();
413 assert!(estimated >= 1000);
414 }
415
416 #[test]
417 fn test_engine_state_update_time() {
418 let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
419
420 // Same boots, newer time -> should update
421 assert!(state.update_time(1, 1100));
422 assert_eq!(state.latest_received_engine_time, 1100);
423
424 // Same boots, older time -> should NOT update
425 assert!(!state.update_time(1, 1050));
426 assert_eq!(state.latest_received_engine_time, 1100);
427
428 // New boot cycle -> should update
429 assert!(state.update_time(2, 500));
430 assert_eq!(state.engine_boots, 2);
431 assert_eq!(state.latest_received_engine_time, 500);
432 }
433
434 /// Test anti-replay protection via latestReceivedEngineTime (RFC 3414 Section 3.2 Step 7b).
435 ///
436 /// The anti-replay mechanism rejects messages with engine time values that are
437 /// not newer than the latest received time. This prevents replay attacks where
438 /// an attacker captures and re-sends old authenticated messages.
439 #[test]
440 fn test_anti_replay_rejects_old_time() {
441 let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
442 state.latest_received_engine_time = 1500; // Simulate having received up to time 1500
443
444 // Attempt to replay a message from time 1400 (before latest)
445 // update_time returns false, indicating the update was rejected
446 assert!(
447 !state.update_time(1, 1400),
448 "Should reject replay: time 1400 < latest 1500"
449 );
450 assert_eq!(
451 state.latest_received_engine_time, 1500,
452 "Latest should not change"
453 );
454
455 // Even time 1500 (equal) should be rejected - must be strictly greater
456 assert!(
457 !state.update_time(1, 1500),
458 "Should reject replay: time 1500 == latest 1500"
459 );
460 assert_eq!(state.latest_received_engine_time, 1500);
461
462 // Time 1501 (newer) should be accepted
463 assert!(
464 state.update_time(1, 1501),
465 "Should accept: time 1501 > latest 1500"
466 );
467 assert_eq!(state.latest_received_engine_time, 1501);
468 }
469
470 /// Test anti-replay across boot cycles.
471 ///
472 /// A new boot cycle (higher boots value) always resets the latest_received_engine_time
473 /// since the agent has rebooted and time values are relative to the boot.
474 #[test]
475 fn test_anti_replay_new_boot_cycle_resets() {
476 let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
477 state.latest_received_engine_time = 5000; // High value from long uptime
478
479 // New boot cycle with lower time value - should accept
480 // because the engine rebooted (boots increased)
481 assert!(
482 state.update_time(2, 100),
483 "New boot cycle should accept even with lower time"
484 );
485 assert_eq!(state.engine_boots, 2);
486 assert_eq!(state.engine_time, 100);
487 assert_eq!(
488 state.latest_received_engine_time, 100,
489 "Latest should reset to new time"
490 );
491
492 // Now subsequent updates in the new boot cycle follow normal rules
493 assert!(
494 !state.update_time(2, 50),
495 "Should reject older time in same boot cycle"
496 );
497 assert!(state.update_time(2, 150), "Should accept newer time");
498 assert_eq!(state.latest_received_engine_time, 150);
499 }
500
501 /// Test anti-replay rejects old boot cycles.
502 ///
503 /// An attacker cannot replay messages from a previous boot cycle.
504 #[test]
505 fn test_anti_replay_rejects_old_boot_cycle() {
506 let mut state = EngineState::new(Bytes::from_static(b"engine"), 5, 1000);
507 state.latest_received_engine_time = 1000;
508
509 // Attempt to use old boot cycle (boots=4) - should reject
510 assert!(
511 !state.update_time(4, 9999),
512 "Should reject old boot cycle even with high time"
513 );
514 assert_eq!(state.engine_boots, 5, "Boots should not change");
515 assert_eq!(
516 state.latest_received_engine_time, 1000,
517 "Latest should not change"
518 );
519
520 // Attempt boots=0 - should reject
521 assert!(!state.update_time(0, 9999), "Should reject boots=0 replay");
522 }
523
524 /// Test anti-replay with exact boundary values.
525 #[test]
526 fn test_anti_replay_boundary_values() {
527 let mut state = EngineState::new(Bytes::from_static(b"engine"), 1, 0);
528
529 // Start with time=0
530 assert_eq!(state.latest_received_engine_time, 0);
531
532 // Time=1 should be accepted (> 0)
533 assert!(state.update_time(1, 1));
534 assert_eq!(state.latest_received_engine_time, 1);
535
536 // Time=0 should be rejected (< 1)
537 assert!(!state.update_time(1, 0));
538
539 // Large time value should work
540 assert!(state.update_time(1, u32::MAX - 1));
541 assert_eq!(state.latest_received_engine_time, u32::MAX - 1);
542
543 // u32::MAX should still work
544 assert!(state.update_time(1, u32::MAX));
545 assert_eq!(state.latest_received_engine_time, u32::MAX);
546
547 // Nothing can be newer than u32::MAX in the same boot cycle
548 assert!(!state.update_time(1, u32::MAX));
549 }
550
551 #[test]
552 fn test_engine_state_time_window() {
553 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
554
555 // Same boots, within window
556 assert!(state.is_in_time_window(1, 1000));
557 assert!(state.is_in_time_window(1, 1100)); // +100s
558 assert!(state.is_in_time_window(1, 900)); // -100s
559
560 // Different boots -> out of window
561 assert!(!state.is_in_time_window(2, 1000));
562 assert!(!state.is_in_time_window(0, 1000));
563
564 // Way outside time window
565 assert!(!state.is_in_time_window(1, 2000)); // +1000s > 150s
566 }
567
568 /// Test the exact 150-second time window boundary per RFC 3414 Section 2.2.3.
569 ///
570 /// The time window is exactly 150 seconds. Messages with time difference
571 /// of exactly 150 seconds should be accepted, but 151 seconds should fail.
572 #[test]
573 fn test_time_window_150s_exact_boundary() {
574 // Use high engine_time to avoid underflow complications
575 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 10000);
576
577 // At exactly +150 seconds from engine_time (10000 + 150 = 10150)
578 // The is_in_time_window compares against estimated_time(), which adds
579 // elapsed time. For a fresh EngineState, elapsed should be ~0.
580 // So msg_time of 10150 should be within window (diff = 150 <= TIME_WINDOW)
581 assert!(
582 state.is_in_time_window(1, 10150),
583 "Message at exactly +150s boundary should be in window"
584 );
585
586 // At exactly +151 seconds (diff = 151 > TIME_WINDOW = 150)
587 assert!(
588 !state.is_in_time_window(1, 10151),
589 "Message at +151s should be outside window"
590 );
591
592 // At exactly -150 seconds (10000 - 150 = 9850)
593 assert!(
594 state.is_in_time_window(1, 9850),
595 "Message at exactly -150s boundary should be in window"
596 );
597
598 // At exactly -151 seconds (10000 - 151 = 9849)
599 assert!(
600 !state.is_in_time_window(1, 9849),
601 "Message at -151s should be outside window"
602 );
603 }
604
605 /// Test time window with maximum engine boots value (2147483647).
606 ///
607 /// Per RFC 3414 Section 2.2.3, when snmpEngineBoots is 2147483647 (latched),
608 /// all messages should be rejected as outside the time window.
609 #[test]
610 fn test_time_window_boots_latched() {
611 // Maximum boots value indicates the engine has been rebooted too many times
612 // and should reject all authenticated messages
613 let state = EngineState::new(Bytes::from_static(b"engine"), 2147483647, 1000);
614
615 // Even with matching boots and same time, should fail when latched
616 assert!(
617 !state.is_in_time_window(2147483647, 1000),
618 "Latched boots should reject all messages"
619 );
620
621 // Any other time should also fail
622 assert!(!state.is_in_time_window(2147483647, 1100));
623 assert!(!state.is_in_time_window(2147483647, 900));
624 }
625
626 /// Test time window edge cases with boot counter differences.
627 ///
628 /// Boot counter must match exactly; any difference means out of window.
629 #[test]
630 fn test_time_window_boots_mismatch() {
631 let state = EngineState::new(Bytes::from_static(b"engine"), 100, 1000);
632
633 // Boots too high
634 assert!(!state.is_in_time_window(101, 1000));
635 assert!(!state.is_in_time_window(200, 1000));
636
637 // Boots too low (replay from previous boot cycle)
638 assert!(!state.is_in_time_window(99, 1000));
639 assert!(!state.is_in_time_window(0, 1000));
640 }
641
642 #[test]
643 fn test_engine_cache_basic_operations() {
644 let cache = EngineCache::new();
645 let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
646
647 // Initially empty
648 assert!(cache.is_empty());
649 assert!(cache.get(&addr).is_none());
650
651 // Insert
652 let state = EngineState::new(Bytes::from_static(b"engine1"), 1, 1000);
653 cache.insert(addr, state);
654
655 assert_eq!(cache.len(), 1);
656 assert!(!cache.is_empty());
657
658 // Get
659 let retrieved = cache.get(&addr).unwrap();
660 assert_eq!(retrieved.engine_id.as_ref(), b"engine1");
661 assert_eq!(retrieved.engine_boots, 1);
662
663 // Update time
664 assert!(cache.update_time(&addr, 1, 1100));
665
666 // Remove
667 let removed = cache.remove(&addr).unwrap();
668 assert_eq!(removed.latest_received_engine_time, 1100);
669 assert!(cache.is_empty());
670 }
671
672 #[test]
673 fn test_parse_discovery_response() {
674 let usm = UsmSecurityParams::new(b"test-engine-id".as_slice(), 42, 12345, b"".as_slice());
675 let encoded = usm.encode();
676
677 let state = parse_discovery_response(&encoded).unwrap();
678 assert_eq!(state.engine_id.as_ref(), b"test-engine-id");
679 assert_eq!(state.engine_boots, 42);
680 assert_eq!(state.engine_time, 12345);
681 }
682
683 #[test]
684 fn test_parse_discovery_response_empty_engine_id() {
685 let usm = UsmSecurityParams::empty();
686 let encoded = usm.encode();
687
688 let result = parse_discovery_response(&encoded);
689 assert!(matches!(
690 *result.unwrap_err(),
691 Error::MalformedResponse { .. }
692 ));
693 }
694
695 #[test]
696 fn test_is_unknown_engine_id_report() {
697 use crate::Value;
698 use crate::VarBind;
699 use crate::pdu::{Pdu, PduType};
700
701 // Report with usmStatsUnknownEngineIDs
702 let mut pdu = Pdu {
703 pdu_type: PduType::Report,
704 request_id: 1,
705 error_status: 0,
706 error_index: 0,
707 varbinds: vec![VarBind {
708 oid: report_oids::unknown_engine_ids(),
709 value: Value::Counter32(1),
710 }],
711 };
712
713 assert!(is_unknown_engine_id_report(&pdu));
714
715 // Different report type
716 pdu.varbinds[0].oid = report_oids::not_in_time_windows();
717 assert!(!is_unknown_engine_id_report(&pdu));
718
719 // Not a Report PDU
720 pdu.pdu_type = PduType::Response;
721 assert!(!is_unknown_engine_id_report(&pdu));
722 }
723
724 // ========================================================================
725 // Engine Boots Overflow Tests (RFC 3414 Section 2.2.3)
726 // ========================================================================
727
728 /// Test that update_time accepts transition to maximum boots value.
729 ///
730 /// When the engine reboots and boots reaches 2147483647 (i32::MAX),
731 /// the update should be accepted since it's a valid new boot cycle.
732 #[test]
733 fn test_engine_boots_transition_to_max() {
734 let mut state = EngineState::new(Bytes::from_static(b"engine"), 2147483646, 1000);
735
736 // Boot cycle to max value should be accepted
737 assert!(
738 state.update_time(2147483647, 100),
739 "Transition to boots=2147483647 should be accepted"
740 );
741 assert_eq!(state.engine_boots, 2147483647);
742 assert_eq!(state.engine_time, 100);
743 }
744
745 /// Test update_time behavior when boots is latched.
746 ///
747 /// The update_time function still tracks received times for anti-replay
748 /// purposes. The security rejection happens in is_in_time_window().
749 /// However, when boots=2147483647, there's no valid "higher" boots value,
750 /// so boot cycle transitions are impossible.
751 #[test]
752 fn test_engine_boots_latched_update_behavior() {
753 let mut state = EngineState::new(Bytes::from_static(b"engine"), 2147483647, 1000);
754
755 // Time tracking still works for same boots
756 assert!(
757 state.update_time(2147483647, 2000),
758 "Time tracking updates should still work"
759 );
760 assert_eq!(state.latest_received_engine_time, 2000);
761
762 // Old time rejected per normal anti-replay
763 assert!(!state.update_time(2147483647, 1500));
764 assert_eq!(state.latest_received_engine_time, 2000);
765
766 // The key security check is in is_in_time_window
767 assert!(
768 !state.is_in_time_window(2147483647, 2000),
769 "Latched state should still reject all messages"
770 );
771 }
772
773 /// Test that time window rejects all messages when boots is latched.
774 ///
775 /// This is the key security property: once an engine's boots counter
776 /// reaches its maximum value, all authenticated messages should be
777 /// rejected to prevent replay attacks.
778 #[test]
779 fn test_engine_boots_latched_time_window_always_fails() {
780 let state = EngineState::new(Bytes::from_static(b"engine"), 2147483647, 1000);
781
782 // All time values should fail when latched
783 assert!(!state.is_in_time_window(2147483647, 0));
784 assert!(!state.is_in_time_window(2147483647, 1000));
785 assert!(!state.is_in_time_window(2147483647, 1001));
786 assert!(!state.is_in_time_window(2147483647, u32::MAX));
787
788 // Even previous boots values should fail
789 assert!(!state.is_in_time_window(2147483646, 1000));
790 assert!(!state.is_in_time_window(0, 1000));
791 }
792
793 /// Test creating EngineState directly with latched boots value.
794 ///
795 /// An agent that has been running for a very long time might already
796 /// be in the latched state when we first discover it.
797 #[test]
798 fn test_engine_state_created_latched() {
799 let state = EngineState::new(Bytes::from_static(b"engine"), 2147483647, 5000);
800
801 assert_eq!(state.engine_boots, 2147483647);
802 assert_eq!(state.engine_time, 5000);
803 assert_eq!(state.latest_received_engine_time, 5000);
804
805 // Should immediately be in latched state
806 assert!(
807 !state.is_in_time_window(2147483647, 5000),
808 "Newly created latched engine should reject all messages"
809 );
810 }
811
812 /// Test that boots values near the maximum work correctly.
813 ///
814 /// Verify normal operation just before reaching the latch point.
815 #[test]
816 fn test_engine_boots_near_max_operates_normally() {
817 let mut state = EngineState::new(Bytes::from_static(b"engine"), 2147483645, 1000);
818
819 // Normal time window checks should work
820 assert!(state.is_in_time_window(2147483645, 1000));
821 assert!(state.is_in_time_window(2147483645, 1100));
822 assert!(!state.is_in_time_window(2147483645, 1200)); // Outside 150s window
823
824 // Should accept boot to 2147483646
825 assert!(state.update_time(2147483646, 500));
826 assert_eq!(state.engine_boots, 2147483646);
827 assert!(state.is_in_time_window(2147483646, 500));
828
829 // Should accept boot to 2147483647 (becomes latched)
830 assert!(state.update_time(2147483647, 100));
831 assert_eq!(state.engine_boots, 2147483647);
832
833 // Now latched - all messages rejected
834 assert!(!state.is_in_time_window(2147483647, 100));
835 }
836
837 /// Test that update_time correctly handles the comparison when
838 /// current boots is high but not yet latched.
839 #[test]
840 fn test_engine_boots_high_value_update_logic() {
841 let mut state = EngineState::new(Bytes::from_static(b"engine"), 2147483640, 1000);
842
843 // Old boot cycles should be rejected
844 assert!(!state.update_time(2147483639, 9999));
845 assert!(!state.update_time(0, 9999));
846
847 // Same boot, older time should be rejected
848 assert!(!state.update_time(2147483640, 500));
849
850 // Same boot, newer time should be accepted
851 assert!(state.update_time(2147483640, 1500));
852 assert_eq!(state.latest_received_engine_time, 1500);
853
854 // New boot should be accepted
855 assert!(state.update_time(2147483641, 100));
856 assert_eq!(state.engine_boots, 2147483641);
857 }
858
859 /// Test EngineCache behavior with latched engines.
860 ///
861 /// Even when latched, time tracking updates are accepted (for anti-replay).
862 /// The security rejection is enforced by is_in_time_window(), not update_time().
863 #[test]
864 fn test_engine_cache_latched_engine() {
865 let cache = EngineCache::new();
866 let addr: SocketAddr = "192.168.1.1:161".parse().unwrap();
867
868 // Insert latched engine
869 cache.insert(
870 addr,
871 EngineState::new(Bytes::from_static(b"latched"), 2147483647, 1000),
872 );
873
874 // Time tracking still works
875 assert!(
876 cache.update_time(&addr, 2147483647, 2000),
877 "Time tracking should update even for latched engine"
878 );
879
880 // Verify state was updated
881 let state = cache.get(&addr).unwrap();
882 assert_eq!(state.latest_received_engine_time, 2000);
883
884 // But the key security property: is_in_time_window rejects
885 assert!(
886 !state.is_in_time_window(2147483647, 2000),
887 "Latched engine should reject all time window checks"
888 );
889 }
890
891 // ========================================================================
892 // msgMaxSize Capping Tests
893 // ========================================================================
894 //
895 // Per net-snmp behavior, agent-reported msgMaxSize values should be capped
896 // to the session's maximum to prevent buffer issues with non-compliant agents.
897
898 /// Test that EngineState stores the agent's advertised msgMaxSize.
899 ///
900 /// The msg_max_size field tracks the maximum message size the remote engine
901 /// can accept, as reported in SNMPv3 message headers.
902 #[test]
903 fn test_engine_state_stores_msg_max_size() {
904 let state = EngineState::with_msg_max_size(Bytes::from_static(b"engine"), 1, 1000, 65507);
905 assert_eq!(state.msg_max_size, 65507);
906 }
907
908 /// Test that the default constructor uses the maximum UDP message size.
909 ///
910 /// When msgMaxSize is not provided (e.g., during basic discovery),
911 /// default to the maximum safe UDP datagram size (65507 bytes).
912 #[test]
913 fn test_engine_state_default_msg_max_size() {
914 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
915 assert_eq!(
916 state.msg_max_size, DEFAULT_MSG_MAX_SIZE,
917 "Default msg_max_size should be the maximum UDP datagram size"
918 );
919 }
920
921 /// Test that msgMaxSize is capped to session maximum.
922 ///
923 /// Non-compliant agents may advertise msgMaxSize values larger than they
924 /// (or we) can actually handle. Values exceeding the session maximum are
925 /// silently capped to prevent buffer issues.
926 #[test]
927 fn test_engine_state_msg_max_size_capped_to_session_max() {
928 // Agent advertises 2GB, but we cap to 65507 (our session max)
929 let state = EngineState::with_msg_max_size_capped(
930 Bytes::from_static(b"engine"),
931 1,
932 1000,
933 2_000_000_000, // Agent claims 2GB
934 65507, // Our session maximum
935 );
936 assert_eq!(
937 state.msg_max_size, 65507,
938 "msg_max_size should be capped to session maximum"
939 );
940 }
941
942 /// Test that msgMaxSize within session maximum is not modified.
943 ///
944 /// When the agent advertises a reasonable value below our maximum,
945 /// it should be stored as-is without capping.
946 #[test]
947 fn test_engine_state_msg_max_size_within_limit_not_capped() {
948 let state = EngineState::with_msg_max_size_capped(
949 Bytes::from_static(b"engine"),
950 1,
951 1000,
952 1472, // Agent claims 1472 (Ethernet MTU - headers)
953 65507, // Our session maximum
954 );
955 assert_eq!(
956 state.msg_max_size, 1472,
957 "msg_max_size within limit should not be capped"
958 );
959 }
960
961 /// Test msgMaxSize capping at exact boundary.
962 ///
963 /// When agent's msgMaxSize exactly equals session maximum, no capping occurs.
964 #[test]
965 fn test_engine_state_msg_max_size_at_exact_boundary() {
966 let state = EngineState::with_msg_max_size_capped(
967 Bytes::from_static(b"engine"),
968 1,
969 1000,
970 65507, // Exactly at session max
971 65507, // Our session maximum
972 );
973 assert_eq!(state.msg_max_size, 65507);
974 }
975
976 /// Test msgMaxSize capping with TCP transport maximum.
977 ///
978 /// TCP transports may have higher limits. Verify capping works with
979 /// the larger TCP message size limit.
980 #[test]
981 fn test_engine_state_msg_max_size_tcp_limit() {
982 const TCP_MAX: u32 = 0x7FFFFFFF; // net-snmp TCP maximum
983
984 // Agent claims i32::MAX, we have same limit
985 let state = EngineState::with_msg_max_size_capped(
986 Bytes::from_static(b"engine"),
987 1,
988 1000,
989 TCP_MAX,
990 TCP_MAX,
991 );
992 assert_eq!(state.msg_max_size, TCP_MAX);
993
994 // Agent claims more than i32::MAX (wrapped negative), cap to limit
995 let state = EngineState::with_msg_max_size_capped(
996 Bytes::from_static(b"engine"),
997 1,
998 1000,
999 u32::MAX, // Larger than any valid msgMaxSize
1000 TCP_MAX,
1001 );
1002 assert_eq!(
1003 state.msg_max_size, TCP_MAX,
1004 "Values exceeding session max should be capped"
1005 );
1006 }
1007
1008 /// Test that EngineState::new uses the default msg_max_size constant.
1009 #[test]
1010 fn test_engine_state_new_uses_default_constant() {
1011 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
1012
1013 // DEFAULT_MSG_MAX_SIZE is the maximum UDP payload (65507)
1014 assert_eq!(state.msg_max_size, DEFAULT_MSG_MAX_SIZE);
1015 }
1016
1017 // ========================================================================
1018 // Engine Time Overflow Tests (RFC 3414 Section 2.2.1)
1019 // ========================================================================
1020 //
1021 // Per RFC 3414, snmpEngineTime is a 31-bit value (0..2147483647).
1022 // When the time value would exceed this, it must not go beyond MAX_ENGINE_TIME.
1023
1024 /// Test that estimated_time caps at MAX_ENGINE_TIME (2^31-1).
1025 ///
1026 /// Per RFC 3414 Section 2.2.1, snmpEngineTime is 31-bit (0..2147483647).
1027 /// If time would exceed this value, it should cap at MAX_ENGINE_TIME rather
1028 /// than continuing to u32::MAX.
1029 #[test]
1030 fn test_estimated_time_caps_at_max_engine_time() {
1031 // Create state with engine_time near the maximum
1032 let state = EngineState::new(Bytes::from_static(b"engine"), 1, MAX_ENGINE_TIME - 10);
1033
1034 // Even though we're adding elapsed time, result should never exceed MAX_ENGINE_TIME
1035 let estimated = state.estimated_time();
1036 assert!(
1037 estimated <= MAX_ENGINE_TIME,
1038 "estimated_time() should never exceed MAX_ENGINE_TIME ({}), got {}",
1039 MAX_ENGINE_TIME,
1040 estimated
1041 );
1042 }
1043
1044 /// Test that estimated_time at MAX_ENGINE_TIME stays at MAX_ENGINE_TIME.
1045 ///
1046 /// When engine_time is already at the maximum, adding more elapsed time
1047 /// should not increase it further.
1048 #[test]
1049 fn test_estimated_time_at_max_stays_at_max() {
1050 let state = EngineState::new(Bytes::from_static(b"engine"), 1, MAX_ENGINE_TIME);
1051
1052 // Should stay at MAX_ENGINE_TIME
1053 let estimated = state.estimated_time();
1054 assert_eq!(
1055 estimated, MAX_ENGINE_TIME,
1056 "estimated_time() at max should stay at MAX_ENGINE_TIME"
1057 );
1058 }
1059
1060 /// Test that engine_time values beyond MAX_ENGINE_TIME are invalid.
1061 ///
1062 /// This verifies the constant value is correct per RFC 3414.
1063 #[test]
1064 fn test_max_engine_time_constant() {
1065 // RFC 3414 specifies 31-bit (0..2147483647), which is i32::MAX
1066 assert_eq!(MAX_ENGINE_TIME, 2147483647);
1067 assert_eq!(MAX_ENGINE_TIME, i32::MAX as u32);
1068 }
1069
1070 /// Test that normal time estimation works below MAX_ENGINE_TIME.
1071 ///
1072 /// For typical time values well below the maximum, estimation should
1073 /// work normally without artificial capping.
1074 #[test]
1075 fn test_estimated_time_normal_operation() {
1076 let state = EngineState::new(Bytes::from_static(b"engine"), 1, 1000);
1077
1078 // For a fresh state, elapsed should be ~0, so estimated should be ~engine_time
1079 let estimated = state.estimated_time();
1080 assert!(
1081 estimated >= 1000,
1082 "estimated_time() should be at least engine_time"
1083 );
1084 // Should not hit the cap
1085 assert!(
1086 estimated < MAX_ENGINE_TIME,
1087 "Normal time values should not hit MAX_ENGINE_TIME cap"
1088 );
1089 }
1090}