Skip to main content

async_snmp/client/
walk.rs

1//! Walk stream implementations.
2
3// Allow complex types for boxed futures in manual Stream implementations.
4// The `pending` fields require `Option<Pin<Box<dyn Future<Output = ...> + Send>>>`
5// which triggers this lint but is the standard pattern for storing futures.
6#![allow(clippy::type_complexity)]
7
8/// Implement `next()` and `collect()` for a Stream type that implements `poll_next`.
9macro_rules! impl_stream_helpers {
10    ($type:ident < $($gen:tt),+ >) => {
11        impl<$($gen),+> $type<$($gen),+>
12        where
13            $($gen: crate::transport::Transport + 'static,)+
14        {
15            /// Get the next varbind, or None when complete.
16            pub async fn next(&mut self) -> Option<crate::error::Result<crate::varbind::VarBind>> {
17                std::future::poll_fn(|cx| std::pin::Pin::new(&mut *self).poll_next(cx)).await
18            }
19
20            /// Collect all remaining varbinds.
21            ///
22            /// If the walk completes with no results, a fallback GET is attempted
23            /// on the base OID to handle scalar objects (e.g. `sysDescr.0`) that a
24            /// GETNEXT/GETBULK walk would step past. The result is only returned
25            /// when it is a real value and the `max_results` cap permits it;
26            /// genuine absence is swallowed and real errors are propagated.
27            pub async fn collect(mut self) -> crate::error::Result<Vec<crate::varbind::VarBind>> {
28                let mut results = Vec::new();
29                while let Some(result) = self.next().await {
30                    results.push(result?);
31                }
32                if results.is_empty() {
33                    crate::client::walk::walk_scalar_fallback(
34                        &self.client,
35                        &self.base_oid,
36                        self.max_results,
37                        &mut results,
38                    )
39                    .await?;
40                }
41                Ok(results)
42            }
43        }
44    };
45}
46
47use std::collections::{HashSet, VecDeque};
48use std::pin::Pin;
49use std::task::{Context, Poll};
50
51use futures_core::Stream;
52
53use crate::error::{Error, Result, WalkAbortReason};
54use crate::oid::Oid;
55use crate::transport::Transport;
56use crate::varbind::VarBind;
57use crate::version::Version;
58
59use super::Client;
60
61/// Walk operation mode.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub enum WalkMode {
64    /// Auto-select based on version (default).
65    /// V1 uses GETNEXT, V2c/V3 uses GETBULK.
66    #[default]
67    Auto,
68    /// Always use GETNEXT (slower but more compatible).
69    GetNext,
70    /// Always use GETBULK (faster, errors on v1).
71    GetBulk,
72}
73
74/// OID ordering behavior during walk operations.
75///
76/// SNMP walks rely on agents returning OIDs in strictly increasing
77/// lexicographic order. However, some buggy agents violate this requirement,
78/// returning OIDs out of order or even repeating OIDs (which would cause
79/// infinite loops).
80///
81/// This enum controls how the library handles ordering violations:
82///
83/// - [`Strict`](Self::Strict) (default): Terminates immediately with
84///   [`Error::WalkAborted`](crate::Error::WalkAborted) on any violation.
85///   Use this unless you know the agent has ordering bugs.
86///
87/// - [`AllowNonIncreasing`](Self::AllowNonIncreasing): Tolerates out-of-order
88///   OIDs but tracks all seen OIDs to detect cycles. Returns
89///   [`Error::WalkAborted`](crate::Error::WalkAborted) if the same OID appears twice.
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
91pub enum OidOrdering {
92    /// Require strictly increasing OIDs (default).
93    ///
94    /// Walk terminates with [`Error::WalkAborted`](crate::Error::WalkAborted)
95    /// on first violation. Most efficient: O(1) memory, O(1) per-item check.
96    #[default]
97    Strict,
98
99    /// Allow non-increasing OIDs, with cycle detection.
100    ///
101    /// Some buggy agents return OIDs out of order. This mode tracks all seen
102    /// OIDs in a `HashSet` to detect cycles, terminating with an error if the
103    /// same OID is returned twice.
104    ///
105    /// **Warning**: This uses O(n) memory where n = number of walk results.
106    /// Always pair with [`ClientBuilder::max_walk_results`] to bound memory
107    /// usage. Cycle detection only catches duplicate OIDs; a pathological
108    /// agent could still return an infinite sequence of unique OIDs within
109    /// the subtree.
110    ///
111    /// [`ClientBuilder::max_walk_results`]: crate::ClientBuilder::max_walk_results
112    AllowNonIncreasing,
113}
114
115enum OidTracker {
116    Strict { last: Option<Oid> },
117    Relaxed { seen: HashSet<Oid> },
118}
119
120/// Outcome of validating a single varbind from a walk response.
121enum VarbindOutcome {
122    /// Varbind is valid and within the subtree; emit it.
123    Yield,
124    /// Walk is complete (end-of-MIB or out-of-subtree).
125    Done,
126    /// Walk should abort with the given error.
127    Abort(Box<Error>),
128}
129
130/// Validate a varbind received during a walk.
131///
132/// Checks end-of-MIB, subtree containment, and OID ordering.
133/// Returns the outcome, updating `oid_tracker` on success.
134fn validate_walk_varbind(
135    vb: &VarBind,
136    base_oid: &Oid,
137    oid_tracker: &mut OidTracker,
138    target: std::net::SocketAddr,
139) -> VarbindOutcome {
140    if vb.value.is_exception() {
141        return VarbindOutcome::Done;
142    }
143    if !vb.oid.starts_with(base_oid) {
144        return VarbindOutcome::Done;
145    }
146    match oid_tracker.check(&vb.oid, target) {
147        Ok(()) => VarbindOutcome::Yield,
148        Err(e) => VarbindOutcome::Abort(e),
149    }
150}
151
152/// Attempt a scalar-fallback GET on `base_oid` when a walk yielded no results.
153///
154/// Scalar objects (e.g. `sysDescr.0`) are not returned by a GETNEXT/GETBULK
155/// walk rooted at the scalar object OID, so a direct GET is used as a fallback
156/// when the walk produced nothing.
157///
158/// The fallback result is appended to `results` only when both:
159/// - the GET returns a real value (not an exception), and
160/// - the walk's `max_results` cap still permits another result (in particular
161///   `Some(0)` yields zero results, matching the streaming cap behaviour).
162///
163/// Genuine absence is swallowed: v2c+ `noSuchObject`/`noSuchInstance`/
164/// `endOfMibView` exception values, and the v1 `noSuchName` error-status. Any
165/// other error (timeout, authentication failure, malformed response, ...) is
166/// propagated to the caller.
167async fn walk_scalar_fallback<T: Transport + 'static>(
168    client: &Client<T>,
169    base_oid: &Oid,
170    max_results: Option<usize>,
171    results: &mut Vec<VarBind>,
172) -> Result<()> {
173    // Respect the walk result cap; Some(0) must yield zero results.
174    if matches!(max_results, Some(max) if results.len() >= max) {
175        return Ok(());
176    }
177    match client.get(base_oid).await {
178        // Real scalar value: return it.
179        Ok(vb) if !vb.value.is_exception() => {
180            results.push(vb);
181            Ok(())
182        }
183        // Genuine absence (v2c+): exception value in an otherwise-Ok response.
184        Ok(_) => Ok(()),
185        // Genuine absence (v1): noSuchName error-status.
186        Err(e)
187            if matches!(
188                &*e,
189                Error::Snmp {
190                    status: crate::error::ErrorStatus::NoSuchName,
191                    ..
192                }
193            ) =>
194        {
195            Ok(())
196        }
197        // Real failure (timeout, auth, malformed, ...): propagate.
198        Err(e) => Err(e),
199    }
200}
201
202impl OidTracker {
203    fn new(ordering: OidOrdering) -> Self {
204        match ordering {
205            OidOrdering::Strict => OidTracker::Strict { last: None },
206            OidOrdering::AllowNonIncreasing => OidTracker::Relaxed {
207                seen: HashSet::new(),
208            },
209        }
210    }
211
212    fn check(&mut self, oid: &Oid, target: std::net::SocketAddr) -> Result<()> {
213        match self {
214            OidTracker::Strict { last } => {
215                if let Some(prev) = last
216                    && oid <= prev
217                {
218                    tracing::debug!(target: "async_snmp::walk", { previous_oid = %prev, current_oid = %oid, %target }, "non-increasing OID detected");
219                    return Err(Error::WalkAborted {
220                        target,
221                        reason: WalkAbortReason::NonIncreasing,
222                    }
223                    .boxed());
224                }
225                *last = Some(oid.clone());
226                Ok(())
227            }
228            OidTracker::Relaxed { seen } => {
229                if !seen.insert(oid.clone()) {
230                    tracing::debug!(target: "async_snmp::walk", { %oid, %target }, "duplicate OID detected (cycle)");
231                    return Err(Error::WalkAborted {
232                        target,
233                        reason: WalkAbortReason::Cycle,
234                    }
235                    .boxed());
236                }
237                Ok(())
238            }
239        }
240    }
241}
242
243/// Async stream for walking an OID subtree using GETNEXT.
244///
245/// Created by [`Client::walk_getnext()`].
246pub struct Walk<T: Transport> {
247    client: Client<T>,
248    base_oid: Oid,
249    current_oid: Oid,
250    /// OID tracker for ordering validation.
251    oid_tracker: OidTracker,
252    /// Maximum number of results to return (None = unlimited).
253    max_results: Option<usize>,
254    /// Count of results returned so far.
255    count: usize,
256    done: bool,
257    pending: Option<Pin<Box<dyn std::future::Future<Output = Result<VarBind>> + Send>>>,
258}
259
260impl<T: Transport> Walk<T> {
261    pub(crate) fn new(
262        client: Client<T>,
263        oid: Oid,
264        ordering: OidOrdering,
265        max_results: Option<usize>,
266    ) -> Self {
267        Self {
268            client,
269            base_oid: oid.clone(),
270            current_oid: oid,
271            oid_tracker: OidTracker::new(ordering),
272            max_results,
273            count: 0,
274            done: false,
275            pending: None,
276        }
277    }
278}
279
280impl_stream_helpers!(Walk<T>);
281
282impl<T: Transport + 'static> Stream for Walk<T> {
283    type Item = Result<VarBind>;
284
285    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
286        if self.done {
287            return Poll::Ready(None);
288        }
289
290        // Check max_results limit
291        if let Some(max) = self.max_results
292            && self.count >= max
293        {
294            self.done = true;
295            return Poll::Ready(None);
296        }
297
298        // Check if we have a pending request
299        if self.pending.is_none() {
300            // Start a new GETNEXT request
301            let client = self.client.clone();
302            let oid = self.current_oid.clone();
303
304            let fut = Box::pin(async move { client.get_next(&oid).await });
305            self.pending = Some(fut);
306        }
307
308        // Poll the pending future
309        let pending = self.pending.as_mut().unwrap();
310        match pending.as_mut().poll(cx) {
311            Poll::Pending => Poll::Pending,
312            Poll::Ready(result) => {
313                self.pending = None;
314
315                match result {
316                    Ok(vb) => {
317                        let target = self.client.peer_addr();
318                        let base_oid = self.base_oid.clone();
319                        match validate_walk_varbind(&vb, &base_oid, &mut self.oid_tracker, target) {
320                            VarbindOutcome::Done => {
321                                self.done = true;
322                                return Poll::Ready(None);
323                            }
324                            VarbindOutcome::Abort(e) => {
325                                self.done = true;
326                                return Poll::Ready(Some(Err(e)));
327                            }
328                            VarbindOutcome::Yield => {}
329                        }
330
331                        // Update current OID for next iteration
332                        self.current_oid = vb.oid.clone();
333                        self.count += 1;
334
335                        Poll::Ready(Some(Ok(vb)))
336                    }
337                    Err(e) => {
338                        if self.client.inner.config.version == Version::V1
339                            && matches!(
340                                &*e,
341                                Error::Snmp {
342                                    status: crate::error::ErrorStatus::NoSuchName,
343                                    ..
344                                }
345                            )
346                        {
347                            self.done = true;
348                            return Poll::Ready(None);
349                        }
350
351                        self.done = true;
352                        Poll::Ready(Some(Err(e)))
353                    }
354                }
355            }
356        }
357    }
358}
359
360/// Async stream for walking an OID subtree using GETBULK.
361///
362/// Created by [`Client::bulk_walk()`].
363pub struct BulkWalk<T: Transport> {
364    client: Client<T>,
365    base_oid: Oid,
366    current_oid: Oid,
367    max_repetitions: i32,
368    /// OID tracker for ordering validation.
369    oid_tracker: OidTracker,
370    /// Maximum number of results to return (None = unlimited).
371    max_results: Option<usize>,
372    /// Count of results returned so far.
373    count: usize,
374    done: bool,
375    /// Buffered results from the last GETBULK response
376    buffer: VecDeque<VarBind>,
377    pending: Option<Pin<Box<dyn std::future::Future<Output = Result<Vec<VarBind>>> + Send>>>,
378}
379
380impl<T: Transport> BulkWalk<T> {
381    pub(crate) fn new(
382        client: Client<T>,
383        oid: Oid,
384        max_repetitions: i32,
385        ordering: OidOrdering,
386        max_results: Option<usize>,
387    ) -> Self {
388        Self {
389            client,
390            base_oid: oid.clone(),
391            current_oid: oid,
392            max_repetitions,
393            oid_tracker: OidTracker::new(ordering),
394            max_results,
395            count: 0,
396            done: false,
397            buffer: VecDeque::new(),
398            pending: None,
399        }
400    }
401}
402
403impl_stream_helpers!(BulkWalk<T>);
404
405impl<T: Transport + 'static> Stream for BulkWalk<T> {
406    type Item = Result<VarBind>;
407
408    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
409        loop {
410            if self.done {
411                return Poll::Ready(None);
412            }
413
414            // Check max_results limit
415            if let Some(max) = self.max_results
416                && self.count >= max
417            {
418                self.done = true;
419                return Poll::Ready(None);
420            }
421
422            // Check if we have buffered results to return
423            if let Some(vb) = self.buffer.pop_front() {
424                let target = self.client.peer_addr();
425                let base_oid = self.base_oid.clone();
426                match validate_walk_varbind(&vb, &base_oid, &mut self.oid_tracker, target) {
427                    VarbindOutcome::Done => {
428                        self.done = true;
429                        return Poll::Ready(None);
430                    }
431                    VarbindOutcome::Abort(e) => {
432                        self.done = true;
433                        return Poll::Ready(Some(Err(e)));
434                    }
435                    VarbindOutcome::Yield => {}
436                }
437
438                // Update current OID for next request
439                self.current_oid = vb.oid.clone();
440                self.count += 1;
441
442                return Poll::Ready(Some(Ok(vb)));
443            }
444
445            // Buffer exhausted, need to fetch more
446            if self.pending.is_none() {
447                let client = self.client.clone();
448                let oid = self.current_oid.clone();
449                let max_rep = self.max_repetitions;
450
451                let fut = Box::pin(async move { client.get_bulk(&[oid], 0, max_rep).await });
452                self.pending = Some(fut);
453            }
454
455            // Poll the pending future
456            let pending = self.pending.as_mut().unwrap();
457            match pending.as_mut().poll(cx) {
458                Poll::Pending => return Poll::Pending,
459                Poll::Ready(result) => {
460                    self.pending = None;
461
462                    match result {
463                        Ok(varbinds) => {
464                            if varbinds.is_empty() {
465                                self.done = true;
466                                return Poll::Ready(None);
467                            }
468
469                            self.buffer = varbinds.into();
470                            // Continue loop to process buffer
471                        }
472                        Err(e) => {
473                            // On tooBig, degrade instead of aborting (RFC 3416
474                            // 4.2.3): halve max-repetitions down to a floor of 1
475                            // and retry the same position. Only surface the error
476                            // if it still fails at max-repetitions = 1.
477                            if self.max_repetitions > 1
478                                && matches!(
479                                    &*e,
480                                    Error::Snmp {
481                                        status: crate::error::ErrorStatus::TooBig,
482                                        ..
483                                    }
484                                )
485                            {
486                                let reduced = (self.max_repetitions / 2).max(1);
487                                tracing::debug!(target: "async_snmp::client", { peer = %self.client.peer_addr(), snmp.max_repetitions = self.max_repetitions, snmp.reduced_max_repetitions = reduced }, "tooBig response, reducing max-repetitions and retrying");
488                                self.max_repetitions = reduced;
489                                // Retry the same position with fewer repetitions.
490                                continue;
491                            }
492
493                            self.done = true;
494                            return Poll::Ready(Some(Err(e)));
495                        }
496                    }
497                }
498            }
499        }
500    }
501}
502
503// ============================================================================
504// Unified WalkStream - auto-selects GETNEXT or GETBULK based on WalkMode
505// ============================================================================
506
507/// Unified walk stream that auto-selects between GETNEXT and GETBULK.
508///
509/// Created by [`Client::walk()`] when using `WalkMode::Auto` or explicit mode selection.
510/// This type wraps either a [`Walk`] or [`BulkWalk`] internally based on:
511/// - `WalkMode::Auto`: Uses GETNEXT for V1, GETBULK for V2c/V3
512/// - `WalkMode::GetNext`: Always uses GETNEXT
513/// - `WalkMode::GetBulk`: Always uses GETBULK (fails on V1)
514pub enum WalkStream<T: Transport> {
515    /// GETNEXT-based walk (used for V1 or when explicitly requested)
516    GetNext(Walk<T>),
517    /// GETBULK-based walk (used for V2c/V3 or when explicitly requested)
518    GetBulk(BulkWalk<T>),
519}
520
521impl<T: Transport> WalkStream<T> {
522    /// Create a new walk stream with auto-selection based on version and walk mode.
523    pub(crate) fn new(
524        client: Client<T>,
525        oid: Oid,
526        version: Version,
527        walk_mode: WalkMode,
528        ordering: OidOrdering,
529        max_results: Option<usize>,
530        max_repetitions: i32,
531    ) -> Result<Self> {
532        let use_bulk = match walk_mode {
533            WalkMode::Auto => version != Version::V1,
534            WalkMode::GetNext => false,
535            WalkMode::GetBulk => {
536                if version == Version::V1 {
537                    return Err(Error::Config("GETBULK is not supported in SNMPv1".into()).boxed());
538                }
539                true
540            }
541        };
542
543        Ok(if use_bulk {
544            WalkStream::GetBulk(BulkWalk::new(
545                client,
546                oid,
547                max_repetitions,
548                ordering,
549                max_results,
550            ))
551        } else {
552            WalkStream::GetNext(Walk::new(client, oid, ordering, max_results))
553        })
554    }
555}
556
557impl<T: Transport + 'static> WalkStream<T> {
558    /// Get the next varbind, or None when complete.
559    pub async fn next(&mut self) -> Option<Result<VarBind>> {
560        std::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx)).await
561    }
562
563    /// Collect all remaining varbinds.
564    ///
565    /// If the walk completes with no results, a fallback GET is attempted on the
566    /// base OID. This handles scalar OIDs (e.g. `sysDescr.0`) where GETNEXT would
567    /// walk past the value. The GET result is only returned if it contains a real
568    /// value (not `NoSuchObject`, `NoSuchInstance`, or `EndOfMibView`) and the
569    /// `max_results` cap permits it. Genuine absence (including the v1
570    /// `noSuchName` error-status) is swallowed; other errors (timeout,
571    /// authentication failure, malformed response) are propagated. This matches
572    /// the fallback behaviour of [`Walk::collect`] and [`BulkWalk::collect`].
573    pub async fn collect(mut self) -> Result<Vec<VarBind>> {
574        let mut results = Vec::new();
575        while let Some(result) = self.next().await {
576            results.push(result?);
577        }
578        if results.is_empty() {
579            let (client, base_oid, max_results) = match &self {
580                WalkStream::GetNext(w) => (&w.client, &w.base_oid, w.max_results),
581                WalkStream::GetBulk(bw) => (&bw.client, &bw.base_oid, bw.max_results),
582            };
583            walk_scalar_fallback(client, base_oid, max_results, &mut results).await?;
584        }
585        Ok(results)
586    }
587}
588
589impl<T: Transport + 'static> Stream for WalkStream<T> {
590    type Item = Result<VarBind>;
591
592    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
593        // SAFETY: We're just projecting the pin to the inner enum variant
594        match self.get_mut() {
595            WalkStream::GetNext(walk) => Pin::new(walk).poll_next(cx),
596            WalkStream::GetBulk(bulk_walk) => Pin::new(bulk_walk).poll_next(cx),
597        }
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use crate::oid;
605    use crate::value::Value;
606
607    fn target_addr() -> std::net::SocketAddr {
608        "127.0.0.1:161".parse().unwrap()
609    }
610
611    #[test]
612    fn test_walk_terminates_on_no_such_object() {
613        let base = oid!(1, 3, 6, 1, 2, 1, 1);
614        let mut tracker = OidTracker::new(OidOrdering::Strict);
615        let vb = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 1, 0), Value::NoSuchObject);
616        assert!(matches!(
617            validate_walk_varbind(&vb, &base, &mut tracker, target_addr()),
618            VarbindOutcome::Done
619        ));
620    }
621
622    #[test]
623    fn test_walk_terminates_on_no_such_instance() {
624        let base = oid!(1, 3, 6, 1, 2, 1, 1);
625        let mut tracker = OidTracker::new(OidOrdering::Strict);
626        let vb = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 1, 0), Value::NoSuchInstance);
627        assert!(matches!(
628            validate_walk_varbind(&vb, &base, &mut tracker, target_addr()),
629            VarbindOutcome::Done
630        ));
631    }
632
633    #[test]
634    fn test_walk_terminates_on_end_of_mib_view() {
635        let base = oid!(1, 3, 6, 1, 2, 1, 1);
636        let mut tracker = OidTracker::new(OidOrdering::Strict);
637        let vb = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 1, 0), Value::EndOfMibView);
638        assert!(matches!(
639            validate_walk_varbind(&vb, &base, &mut tracker, target_addr()),
640            VarbindOutcome::Done
641        ));
642    }
643
644    #[test]
645    fn test_walk_yields_normal_value() {
646        let base = oid!(1, 3, 6, 1, 2, 1, 1);
647        let mut tracker = OidTracker::new(OidOrdering::Strict);
648        let vb = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 1, 0), Value::Integer(42));
649        assert!(matches!(
650            validate_walk_varbind(&vb, &base, &mut tracker, target_addr()),
651            VarbindOutcome::Yield
652        ));
653    }
654
655    #[test]
656    fn test_walk_strict_aborts_on_non_increasing_oid() {
657        let base = oid!(1, 3, 6, 1, 2, 1, 1);
658        let mut tracker = OidTracker::new(OidOrdering::Strict);
659
660        let vb1 = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 2, 0), Value::Integer(1));
661        assert!(matches!(
662            validate_walk_varbind(&vb1, &base, &mut tracker, target_addr()),
663            VarbindOutcome::Yield
664        ));
665
666        // A lower in-subtree OID must abort with NonIncreasing.
667        let vb2 = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 1, 0), Value::Integer(2));
668        match validate_walk_varbind(&vb2, &base, &mut tracker, target_addr()) {
669            VarbindOutcome::Abort(e) => match *e {
670                Error::WalkAborted { reason, .. } => {
671                    assert_eq!(reason, WalkAbortReason::NonIncreasing);
672                }
673                other => panic!("expected WalkAborted, got {other:?}"),
674            },
675            _ => panic!("expected Abort outcome"),
676        }
677    }
678
679    #[test]
680    fn test_walk_strict_aborts_on_equal_oid() {
681        let base = oid!(1, 3, 6, 1, 2, 1, 1);
682        let mut tracker = OidTracker::new(OidOrdering::Strict);
683
684        let vb1 = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 2, 0), Value::Integer(1));
685        assert!(matches!(
686            validate_walk_varbind(&vb1, &base, &mut tracker, target_addr()),
687            VarbindOutcome::Yield
688        ));
689
690        // Same OID again (the `<=` boundary) must also abort with NonIncreasing.
691        let vb2 = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 2, 0), Value::Integer(1));
692        match validate_walk_varbind(&vb2, &base, &mut tracker, target_addr()) {
693            VarbindOutcome::Abort(e) => match *e {
694                Error::WalkAborted { reason, .. } => {
695                    assert_eq!(reason, WalkAbortReason::NonIncreasing);
696                }
697                other => panic!("expected WalkAborted, got {other:?}"),
698            },
699            _ => panic!("expected Abort outcome"),
700        }
701    }
702
703    #[test]
704    fn test_walk_relaxed_aborts_on_duplicate_oid_cycle() {
705        let base = oid!(1, 3, 6, 1, 2, 1, 1);
706        let mut tracker = OidTracker::new(OidOrdering::AllowNonIncreasing);
707
708        let vb1 = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 2, 0), Value::Integer(1));
709        assert!(matches!(
710            validate_walk_varbind(&vb1, &base, &mut tracker, target_addr()),
711            VarbindOutcome::Yield
712        ));
713
714        // Same OID again must abort with Cycle (not NonIncreasing).
715        let vb2 = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 2, 0), Value::Integer(1));
716        match validate_walk_varbind(&vb2, &base, &mut tracker, target_addr()) {
717            VarbindOutcome::Abort(e) => match *e {
718                Error::WalkAborted { reason, .. } => {
719                    assert_eq!(reason, WalkAbortReason::Cycle);
720                }
721                other => panic!("expected WalkAborted, got {other:?}"),
722            },
723            _ => panic!("expected Abort outcome"),
724        }
725    }
726
727    #[test]
728    fn test_walk_relaxed_allows_non_increasing_distinct_oid() {
729        let base = oid!(1, 3, 6, 1, 2, 1, 1);
730        let mut tracker = OidTracker::new(OidOrdering::AllowNonIncreasing);
731
732        // Higher OID first.
733        let vb1 = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 3, 0), Value::Integer(1));
734        assert!(matches!(
735            validate_walk_varbind(&vb1, &base, &mut tracker, target_addr()),
736            VarbindOutcome::Yield
737        ));
738
739        // Lower, but distinct, in-subtree OID: relaxed mode tolerates this (no abort).
740        let vb2 = VarBind::new(oid!(1, 3, 6, 1, 2, 1, 1, 1, 0), Value::Integer(2));
741        assert!(matches!(
742            validate_walk_varbind(&vb2, &base, &mut tracker, target_addr()),
743            VarbindOutcome::Yield
744        ));
745    }
746
747    // -------------------------------------------------------------------------
748    // Mock transport that returns tooBig for GETBULK when max-repetitions
749    // exceeds a threshold, otherwise returns a terminating response. Used to
750    // exercise BulkWalk degradation (RFC 3416 4.2.3): on tooBig the walk halves
751    // max-repetitions and retries the same position instead of aborting.
752    // -------------------------------------------------------------------------
753
754    use crate::client::ClientConfig;
755    use crate::error::ErrorStatus;
756    use crate::message::CommunityMessage;
757    use crate::pdu::{Pdu, PduType};
758    use bytes::Bytes;
759    use std::collections::VecDeque;
760    use std::net::SocketAddr;
761    use std::sync::{Arc, Mutex};
762
763    #[derive(Clone)]
764    struct BulkTooBigTransport {
765        /// Highest max-repetitions the agent will accept; larger requests return tooBig.
766        max_repetitions: i32,
767        /// Records (request_id, max_repetitions) seen by `send`, drained by `recv`.
768        pending: Arc<Mutex<VecDeque<(i32, i32)>>>,
769        /// Total number of tooBig responses emitted.
770        too_big_count: Arc<Mutex<usize>>,
771    }
772
773    impl BulkTooBigTransport {
774        fn new(max_repetitions: i32) -> Self {
775            Self {
776                max_repetitions,
777                pending: Arc::new(Mutex::new(VecDeque::new())),
778                too_big_count: Arc::new(Mutex::new(0)),
779            }
780        }
781    }
782
783    impl Transport for BulkTooBigTransport {
784        fn send(&self, data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send {
785            let request_id = crate::transport::extract_request_id(data).unwrap_or(1);
786            let msg = CommunityMessage::decode(Bytes::copy_from_slice(data)).unwrap();
787            let pdu = msg.pdu.standard().unwrap();
788            // For GETBULK, error_index carries max-repetitions.
789            let max_rep = pdu.error_index;
790            self.pending
791                .lock()
792                .unwrap()
793                .push_back((request_id, max_rep));
794            async { Ok(()) }
795        }
796
797        fn recv(
798            &self,
799            _request_id: i32,
800        ) -> impl std::future::Future<Output = Result<(Bytes, SocketAddr)>> + Send {
801            let (request_id, max_rep) = self.pending.lock().unwrap().pop_front().unwrap_or((1, 0));
802            let threshold = self.max_repetitions;
803            let too_big_count = self.too_big_count.clone();
804            let peer: SocketAddr = "127.0.0.1:161".parse().unwrap();
805
806            async move {
807                let pdu = if max_rep > threshold {
808                    *too_big_count.lock().unwrap() += 1;
809                    Pdu {
810                        pdu_type: PduType::Response,
811                        request_id,
812                        error_status: ErrorStatus::TooBig.as_i32(),
813                        error_index: 0,
814                        varbinds: vec![],
815                    }
816                } else {
817                    // One in-subtree value, then EndOfMibView to terminate the walk.
818                    let varbinds = vec![
819                        VarBind::new(oid!(1, 3, 6, 1, 2, 1, 2, 1, 0), Value::Integer(1)),
820                        VarBind::new(oid!(1, 3, 6, 1, 2, 1, 2, 2, 0), Value::EndOfMibView),
821                    ];
822                    Pdu {
823                        pdu_type: PduType::Response,
824                        request_id,
825                        error_status: 0,
826                        error_index: 0,
827                        varbinds,
828                    }
829                };
830
831                let msg = CommunityMessage::v2c(Bytes::from_static(b"public"), pdu);
832                Ok((msg.encode(), peer))
833            }
834        }
835
836        fn peer_addr(&self) -> SocketAddr {
837            "127.0.0.1:161".parse().unwrap()
838        }
839
840        fn local_addr(&self) -> SocketAddr {
841            "127.0.0.1:0".parse().unwrap()
842        }
843
844        fn is_reliable(&self) -> bool {
845            true
846        }
847    }
848
849    #[tokio::test]
850    async fn bulk_walk_degrades_max_repetitions_on_too_big() {
851        // Agent accepts at most max-repetitions=4. Starting at 25, the walk must
852        // halve (25 -> 12 -> 6 -> 3) and retry the same position until it fits,
853        // rather than surfacing the tooBig error.
854        let transport = BulkTooBigTransport::new(4);
855        let too_big_count = transport.too_big_count.clone();
856        let config = ClientConfig {
857            version: Version::V2c,
858            retry: crate::client::retry::Retry::none(),
859            ..Default::default()
860        };
861        let client = Client::new(transport, config);
862
863        let results = client
864            .bulk_walk(oid!(1, 3, 6, 1, 2, 1, 2), 25)
865            .collect()
866            .await
867            .unwrap();
868
869        // The reduced request succeeded and yielded the in-subtree varbind.
870        assert_eq!(results.len(), 1);
871        assert_eq!(results[0].oid, oid!(1, 3, 6, 1, 2, 1, 2, 1, 0));
872        // At least one tooBig was observed and recovered from.
873        assert!(*too_big_count.lock().unwrap() >= 1);
874    }
875
876    #[tokio::test]
877    async fn bulk_walk_too_big_at_min_repetitions_is_unrecoverable() {
878        // Agent returns tooBig even at max-repetitions=1: degradation bottoms out
879        // and the error is surfaced.
880        let transport = BulkTooBigTransport::new(0);
881        let config = ClientConfig {
882            version: Version::V2c,
883            retry: crate::client::retry::Retry::none(),
884            ..Default::default()
885        };
886        let client = Client::new(transport, config);
887
888        let err = client
889            .bulk_walk(oid!(1, 3, 6, 1, 2, 1, 2), 8)
890            .collect()
891            .await
892            .unwrap_err();
893
894        assert!(
895            matches!(
896                &*err,
897                Error::Snmp {
898                    status: ErrorStatus::TooBig,
899                    ..
900                }
901            ),
902            "expected TooBig, got: {err}"
903        );
904    }
905
906    // -------------------------------------------------------------------------
907    // Mock transport exercising the scalar-fallback GET. Any GETNEXT/GETBULK
908    // terminates the walk immediately (EndOfMibView) so the walk yields nothing
909    // and `collect()` performs its fallback GET on the base OID. The GET
910    // response is governed by `ScalarGetMode`.
911    // -------------------------------------------------------------------------
912
913    #[derive(Clone, Copy)]
914    enum ScalarGetMode {
915        /// GET returns a real scalar value.
916        Value,
917        /// GET returns a noSuchInstance exception (genuine absence).
918        Absent,
919        /// GET returns a non-absence SNMP error (genErr).
920        Error,
921    }
922
923    #[derive(Clone)]
924    struct ScalarFallbackTransport {
925        mode: ScalarGetMode,
926        /// Records (request_id, pdu_type) seen by `send`, drained by `recv`.
927        pending: Arc<Mutex<VecDeque<(i32, PduType)>>>,
928    }
929
930    impl ScalarFallbackTransport {
931        fn new(mode: ScalarGetMode) -> Self {
932            Self {
933                mode,
934                pending: Arc::new(Mutex::new(VecDeque::new())),
935            }
936        }
937    }
938
939    impl Transport for ScalarFallbackTransport {
940        fn send(&self, data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send {
941            let request_id = crate::transport::extract_request_id(data).unwrap_or(1);
942            let msg = CommunityMessage::decode(Bytes::copy_from_slice(data)).unwrap();
943            let pdu = msg.pdu.standard().unwrap();
944            self.pending
945                .lock()
946                .unwrap()
947                .push_back((request_id, pdu.pdu_type));
948            async { Ok(()) }
949        }
950
951        fn recv(
952            &self,
953            _request_id: i32,
954        ) -> impl std::future::Future<Output = Result<(Bytes, SocketAddr)>> + Send {
955            let (request_id, pdu_type) = self
956                .pending
957                .lock()
958                .unwrap()
959                .pop_front()
960                .unwrap_or((1, PduType::GetRequest));
961            let mode = self.mode;
962            let peer: SocketAddr = "127.0.0.1:161".parse().unwrap();
963            let base = oid!(1, 3, 6, 1, 2, 1, 1, 1, 0);
964
965            async move {
966                let pdu = match pdu_type {
967                    // Any walk step terminates immediately with no in-subtree value.
968                    PduType::GetNextRequest | PduType::GetBulkRequest => Pdu {
969                        pdu_type: PduType::Response,
970                        request_id,
971                        error_status: 0,
972                        error_index: 0,
973                        varbinds: vec![VarBind::new(
974                            oid!(1, 3, 6, 1, 2, 1, 99),
975                            Value::EndOfMibView,
976                        )],
977                    },
978                    // Fallback GET on the base OID.
979                    _ => match mode {
980                        ScalarGetMode::Value => Pdu {
981                            pdu_type: PduType::Response,
982                            request_id,
983                            error_status: 0,
984                            error_index: 0,
985                            varbinds: vec![VarBind::new(base.clone(), Value::Integer(7))],
986                        },
987                        ScalarGetMode::Absent => Pdu {
988                            pdu_type: PduType::Response,
989                            request_id,
990                            error_status: 0,
991                            error_index: 0,
992                            varbinds: vec![VarBind::new(base.clone(), Value::NoSuchInstance)],
993                        },
994                        ScalarGetMode::Error => Pdu {
995                            pdu_type: PduType::Response,
996                            request_id,
997                            error_status: ErrorStatus::GenErr.as_i32(),
998                            error_index: 1,
999                            varbinds: vec![VarBind::new(base.clone(), Value::Null)],
1000                        },
1001                    },
1002                };
1003                let msg = CommunityMessage::v2c(Bytes::from_static(b"public"), pdu);
1004                Ok((msg.encode(), peer))
1005            }
1006        }
1007
1008        fn peer_addr(&self) -> SocketAddr {
1009            "127.0.0.1:161".parse().unwrap()
1010        }
1011
1012        fn local_addr(&self) -> SocketAddr {
1013            "127.0.0.1:0".parse().unwrap()
1014        }
1015
1016        fn is_reliable(&self) -> bool {
1017            true
1018        }
1019    }
1020
1021    fn scalar_client(
1022        mode: ScalarGetMode,
1023        max_walk_results: Option<usize>,
1024    ) -> Client<ScalarFallbackTransport> {
1025        let config = ClientConfig {
1026            version: Version::V2c,
1027            retry: crate::client::retry::Retry::none(),
1028            max_walk_results,
1029            ..Default::default()
1030        };
1031        Client::new(ScalarFallbackTransport::new(mode), config)
1032    }
1033
1034    #[tokio::test]
1035    async fn scalar_fallback_agrees_across_collect_paths() {
1036        // The scalar GET returns a real value; every collect path must surface
1037        // it identically (previously only WalkStream::collect did the fallback).
1038        let base = oid!(1, 3, 6, 1, 2, 1, 1, 1, 0);
1039        let expected = VarBind::new(base.clone(), Value::Integer(7));
1040
1041        let walk_getnext = scalar_client(ScalarGetMode::Value, None)
1042            .walk_getnext(base.clone())
1043            .collect()
1044            .await
1045            .unwrap();
1046        let bulk_walk = scalar_client(ScalarGetMode::Value, None)
1047            .bulk_walk(base.clone(), 10)
1048            .collect()
1049            .await
1050            .unwrap();
1051        let walk_stream = scalar_client(ScalarGetMode::Value, None)
1052            .walk(base.clone())
1053            .unwrap()
1054            .collect()
1055            .await
1056            .unwrap();
1057
1058        assert_eq!(walk_getnext, vec![expected.clone()]);
1059        assert_eq!(bulk_walk, vec![expected.clone()]);
1060        assert_eq!(walk_stream, vec![expected]);
1061    }
1062
1063    #[tokio::test]
1064    async fn scalar_fallback_swallows_genuine_absence() {
1065        // noSuchInstance from the fallback GET is genuine absence: yields nothing.
1066        let base = oid!(1, 3, 6, 1, 2, 1, 1, 1, 0);
1067        for results in [
1068            scalar_client(ScalarGetMode::Absent, None)
1069                .walk_getnext(base.clone())
1070                .collect()
1071                .await
1072                .unwrap(),
1073            scalar_client(ScalarGetMode::Absent, None)
1074                .walk(base.clone())
1075                .unwrap()
1076                .collect()
1077                .await
1078                .unwrap(),
1079        ] {
1080            assert!(results.is_empty());
1081        }
1082    }
1083
1084    #[tokio::test]
1085    async fn scalar_fallback_propagates_non_absence_error() {
1086        // A genErr from the fallback GET is not absence and must propagate
1087        // instead of being swallowed by a catch-all.
1088        let base = oid!(1, 3, 6, 1, 2, 1, 1, 1, 0);
1089
1090        let err = scalar_client(ScalarGetMode::Error, None)
1091            .walk_getnext(base.clone())
1092            .collect()
1093            .await
1094            .unwrap_err();
1095        assert!(
1096            matches!(
1097                &*err,
1098                Error::Snmp {
1099                    status: ErrorStatus::GenErr,
1100                    ..
1101                }
1102            ),
1103            "expected GenErr, got: {err}"
1104        );
1105
1106        let err = scalar_client(ScalarGetMode::Error, None)
1107            .walk(base.clone())
1108            .unwrap()
1109            .collect()
1110            .await
1111            .unwrap_err();
1112        assert!(
1113            matches!(
1114                &*err,
1115                Error::Snmp {
1116                    status: ErrorStatus::GenErr,
1117                    ..
1118                }
1119            ),
1120            "expected GenErr, got: {err}"
1121        );
1122    }
1123
1124    #[tokio::test]
1125    async fn scalar_fallback_respects_max_results_zero() {
1126        // max_walk_results = Some(0) caps the walk at zero results, so the
1127        // fallback GET must not add a result even when the scalar exists.
1128        let base = oid!(1, 3, 6, 1, 2, 1, 1, 1, 0);
1129
1130        let walk_getnext = scalar_client(ScalarGetMode::Value, Some(0))
1131            .walk_getnext(base.clone())
1132            .collect()
1133            .await
1134            .unwrap();
1135        let walk_stream = scalar_client(ScalarGetMode::Value, Some(0))
1136            .walk(base.clone())
1137            .unwrap()
1138            .collect()
1139            .await
1140            .unwrap();
1141
1142        assert!(walk_getnext.is_empty());
1143        assert!(walk_stream.is_empty());
1144    }
1145}