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