Skip to main content

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}