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