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