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}