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