Skip to main content

async_snmp/client/
mod.rs

1//! SNMP client implementation.
2
3mod auth;
4mod builder;
5mod retry;
6mod v3;
7mod walk;
8
9pub use auth::{Auth, CommunityVersion};
10pub use builder::{ClientBuilder, Target};
11pub use retry::{Backoff, Retry, RetryBuilder};
12
13// New unified entry point
14impl Client<UdpHandle> {
15    /// Create a new SNMP client builder.
16    ///
17    /// This is the single entry point for client construction, supporting all
18    /// SNMP versions (v1, v2c, v3) through the [`Auth`] enum.
19    ///
20    /// # Example
21    ///
22    /// ```rust,no_run
23    /// use async_snmp::{Auth, Client, Retry};
24    /// use std::time::Duration;
25    ///
26    /// # async fn example() -> async_snmp::Result<()> {
27    /// // (host, port) tuple - convenient when host and port are separate
28    /// let client = Client::builder(("192.168.1.1", 161), Auth::v2c("public"))
29    ///     .connect().await?;
30    ///
31    /// // Combined address string (port defaults to 161 if omitted)
32    /// let client = Client::builder("switch.local", Auth::v2c("public"))
33    ///     .connect().await?;
34    ///
35    /// // SocketAddr works too
36    /// let addr: std::net::SocketAddr = "192.168.1.1:161".parse().unwrap();
37    /// let client = Client::builder(addr, Auth::v2c("public"))
38    ///     .connect().await?;
39    /// # Ok(())
40    /// # }
41    /// ```
42    pub fn builder(target: impl Into<Target>, auth: impl Into<Auth>) -> ClientBuilder {
43        ClientBuilder::new(target, auth)
44    }
45}
46use crate::error::internal::DecodeErrorKind;
47use crate::error::{Error, ErrorStatus, Result};
48use crate::message::{CommunityMessage, Message};
49use crate::oid::Oid;
50use crate::pdu::{GetBulkPdu, Pdu, PduType, TrapV1Pdu};
51use crate::transport::Transport;
52use crate::transport::UdpHandle;
53use crate::v3::{EngineCache, EngineState, SaltCounter};
54use crate::value::Value;
55use crate::varbind::VarBind;
56use crate::version::Version;
57use bytes::Bytes;
58use std::net::SocketAddr;
59use std::pin::Pin;
60use std::sync::Arc;
61use std::sync::RwLock;
62use std::time::{Duration, Instant};
63use tokio::sync::Mutex as AsyncMutex;
64use tracing::{Span, instrument};
65
66pub use crate::v3::{DerivedKeys, UsmConfig};
67pub use walk::{BulkWalk, OidOrdering, Walk, WalkMode, WalkStream};
68
69// ============================================================================
70// Shared helpers
71// ============================================================================
72
73/// Convert a configured `u32` `max_repetitions` to the `i32` GETBULK wire
74/// field, saturating instead of wrapping.
75///
76/// RFC 3416 Section 4.2.3 specifies `max-repetitions` as `INTEGER
77/// (0..2147483647)`. `ClientBuilder::max_repetitions` takes a `u32`, so a
78/// configured value above `i32::MAX` would wrap to a negative number under a
79/// plain `as i32` cast; this saturates to `i32::MAX` instead, which
80/// `GetBulkPdu::encode`'s own clamp (`.max(0)`) would otherwise silently turn
81/// into `0` (disabling repetitions).
82pub(crate) fn max_repetitions_to_wire(max_repetitions: u32) -> i32 {
83    i32::try_from(max_repetitions).unwrap_or(i32::MAX)
84}
85
86/// Extract an SNMP-level error from a PDU and convert it to an `Error::Snmp`.
87///
88/// Returns `Some(err)` if the PDU carries an SNMP error status, `None` otherwise.
89/// The `error_index` field is 1-based; 0 means the error applies to the whole PDU.
90pub(crate) fn pdu_to_snmp_error(pdu: &Pdu, target: SocketAddr) -> Option<Box<Error>> {
91    if !pdu.is_error() {
92        return None;
93    }
94    let status = pdu.error_status_enum();
95    let oid = (pdu.error_index as usize)
96        .checked_sub(1)
97        .and_then(|idx| pdu.varbinds.get(idx))
98        .map(|vb| vb.oid.clone());
99    Some(
100        Error::Snmp {
101            target,
102            status,
103            index: pdu.error_index.try_into().unwrap_or(0),
104            oid,
105        }
106        .boxed(),
107    )
108}
109
110// ============================================================================
111// Default configuration constants
112// ============================================================================
113
114/// Default timeout for SNMP requests.
115pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
116
117/// Default maximum OIDs per request.
118///
119/// Requests with more OIDs than this limit are automatically split into
120/// multiple batches.
121pub const DEFAULT_MAX_OIDS_PER_REQUEST: usize = 10;
122
123/// Default max-repetitions for GETBULK operations.
124///
125/// Controls how many values are requested per GETBULK PDU during walks.
126pub const DEFAULT_MAX_REPETITIONS: u32 = 25;
127
128/// SNMP client.
129///
130/// Generic over transport type, with `UdpHandle` as default.
131pub struct Client<T: Transport = UdpHandle> {
132    inner: Arc<ClientInner<T>>,
133}
134
135impl<T: Transport> Clone for Client<T> {
136    fn clone(&self) -> Self {
137        Self {
138            inner: Arc::clone(&self.inner),
139        }
140    }
141}
142
143struct ClientEngine {
144    state: EngineState,
145    derived_keys: DerivedKeys,
146}
147
148struct ClientInner<T: Transport> {
149    transport: T,
150    config: ClientConfig,
151    /// Coherent V3 identity, trusted time, and identity-localized keys.
152    engine: RwLock<Option<ClientEngine>>,
153    /// Salt counter for privacy (V3)
154    salt_counter: SaltCounter,
155    /// Shared engine cache (V3, optional)
156    engine_cache: Option<Arc<EngineCache>>,
157    /// Serializes concurrent discovery attempts so only one runs at a time.
158    discovery_lock: AsyncMutex<()>,
159    /// Keys derived against the local authoritative engine ID for V3 traps.
160    local_derived_keys: RwLock<Option<DerivedKeys>>,
161    #[cfg(test)]
162    deferred_authenticated_update_hook: RwLock<Option<Arc<dyn Fn() + Send + Sync>>>,
163}
164
165/// Client configuration.
166///
167/// Most users should use [`ClientBuilder`] rather than constructing this directly.
168#[derive(Clone)]
169pub struct ClientConfig {
170    /// SNMP version (default: V2c)
171    pub version: Version,
172    /// Community string for v1/v2c (default: "public")
173    pub community: Bytes,
174    /// Request timeout (default: 5 seconds)
175    pub timeout: Duration,
176    /// Retry configuration (default: 3 retries, 1-second delay)
177    pub retry: Retry,
178    /// Maximum OIDs per request (default: 10)
179    pub max_oids_per_request: usize,
180    /// `SNMPv3` security configuration (default: None)
181    pub v3_security: Option<UsmConfig>,
182    /// Permit one packet-local correction from an unauthenticated
183    /// `usmStatsNotInTimeWindows` Report on an authenticated V3 operation.
184    ///
185    /// This is disabled by default because the Report's boots/time tuple is
186    /// unauthenticated and can cause one authenticated packet to be sent with
187    /// attacker-selected time fields. The tuple is never stored as trusted
188    /// engine state; only a subsequent authenticated, fully matched Response
189    /// may advance that state.
190    pub allow_unauthenticated_v3_time_correction: bool,
191    /// Walk operation mode (default: Auto)
192    pub walk_mode: WalkMode,
193    /// OID ordering behavior during walk operations (default: Strict)
194    pub oid_ordering: OidOrdering,
195    /// Maximum results from a single walk operation (default: None/unlimited)
196    pub max_walk_results: Option<usize>,
197    /// Max-repetitions for GETBULK operations (default: 25)
198    pub max_repetitions: u32,
199    /// Local authoritative engine state for V3 trap sending (default: None).
200    ///
201    /// Per RFC 3412 Section 6.4, the sender is authoritative for trap PDUs.
202    /// Construct this through the persistence-enforcing
203    /// [`AuthoritativeEngine`](crate::v3::AuthoritativeEngine) API.
204    pub local_authoritative_engine: Option<crate::v3::AuthoritativeEngine>,
205}
206
207impl Default for ClientConfig {
208    /// Returns configuration for `SNMPv2c` with community "public".
209    ///
210    /// See field documentation for all default values.
211    fn default() -> Self {
212        Self {
213            version: Version::V2c,
214            community: Bytes::from_static(b"public"),
215            timeout: DEFAULT_TIMEOUT,
216            retry: Retry::default(),
217            max_oids_per_request: DEFAULT_MAX_OIDS_PER_REQUEST,
218            v3_security: None,
219            allow_unauthenticated_v3_time_correction: false,
220            walk_mode: WalkMode::Auto,
221            oid_ordering: OidOrdering::Strict,
222            max_walk_results: None,
223            max_repetitions: DEFAULT_MAX_REPETITIONS,
224            local_authoritative_engine: None,
225        }
226    }
227}
228
229impl ClientConfig {
230    /// Return a copy of this config with invalid values clamped to safe defaults.
231    ///
232    /// [`Client::new`] and [`Client::with_engine_cache`] accept a raw
233    /// [`ClientConfig`], bypassing the [`ClientBuilder`](crate::ClientBuilder)
234    /// validation. This guards against values that would otherwise panic at
235    /// request time, such as `max_oids_per_request == 0` reaching
236    /// [`slice::chunks`], which panics on a chunk size of 0.
237    #[must_use]
238    fn sanitized(mut self) -> Self {
239        if self.max_oids_per_request == 0 {
240            self.max_oids_per_request = DEFAULT_MAX_OIDS_PER_REQUEST;
241        }
242        self
243    }
244}
245
246impl<T: Transport> Client<T> {
247    /// Create a new client with the given transport and config.
248    ///
249    /// For most use cases, prefer [`Client::builder()`] which provides a more
250    /// ergonomic API. Use this constructor when you need fine-grained control
251    /// over transport configuration (e.g., TCP connection timeout, keepalive
252    /// settings) or when using a custom [`Transport`] implementation.
253    pub fn new(transport: T, config: ClientConfig) -> Self {
254        Self {
255            inner: Arc::new(ClientInner {
256                transport,
257                config: config.sanitized(),
258                engine: RwLock::new(None),
259                salt_counter: SaltCounter::new(),
260                engine_cache: None,
261                discovery_lock: AsyncMutex::new(()),
262                local_derived_keys: RwLock::new(None),
263                #[cfg(test)]
264                deferred_authenticated_update_hook: RwLock::new(None),
265            }),
266        }
267    }
268
269    /// Create a new V3 client with a shared engine cache.
270    pub fn with_engine_cache(
271        transport: T,
272        config: ClientConfig,
273        engine_cache: Arc<EngineCache>,
274    ) -> Self {
275        Self {
276            inner: Arc::new(ClientInner {
277                transport,
278                config: config.sanitized(),
279                engine: RwLock::new(None),
280                salt_counter: SaltCounter::new(),
281                engine_cache: Some(engine_cache),
282                discovery_lock: AsyncMutex::new(()),
283                local_derived_keys: RwLock::new(None),
284                #[cfg(test)]
285                deferred_authenticated_update_hook: RwLock::new(None),
286            }),
287        }
288    }
289
290    /// Get the peer (target) address.
291    ///
292    /// Returns the remote address that this client sends requests to.
293    /// Named to match [`std::net::TcpStream::peer_addr()`].
294    #[must_use]
295    pub fn peer_addr(&self) -> SocketAddr {
296        self.inner.transport.peer_addr()
297    }
298
299    /// Generate next request ID.
300    ///
301    /// Uses the transport's allocator (backed by a global counter).
302    fn next_request_id(&self) -> i32 {
303        self.inner.transport.alloc_request_id()
304    }
305
306    /// Check if using V3 with authentication/encryption configured.
307    fn is_v3(&self) -> bool {
308        self.inner.config.version == Version::V3 && self.inner.config.v3_security.is_some()
309    }
310
311    /// Send a request and wait for response (internal helper with pre-encoded data).
312    #[instrument(
313        level = "debug",
314        skip(self, data),
315        fields(
316            snmp.target = %self.peer_addr(),
317            snmp.request_id = request_id,
318            snmp.attempt = tracing::field::Empty,
319            snmp.elapsed_ms = tracing::field::Empty,
320        )
321    )]
322    async fn send_and_recv(&self, request_id: i32, data: &[u8]) -> Result<Pdu> {
323        let start = Instant::now();
324        let mut last_error: Option<Box<Error>> = None;
325        let max_attempts = if self.inner.transport.is_reliable() {
326            0
327        } else {
328            self.inner.config.retry.max_attempts
329        };
330
331        for attempt in 0..=max_attempts {
332            Span::current().record("snmp.attempt", attempt);
333            if attempt > 0 {
334                tracing::debug!(target: "async_snmp::client", "retrying request");
335            }
336
337            // Register (or re-register) with fresh deadline before sending
338            self.inner
339                .transport
340                .register_request(request_id, self.inner.config.timeout);
341
342            // Send request and wait for response as a single unit. Combining the
343            // two lets reliable transports (TCP) own their stream lock for the
344            // whole exchange, so a cancelled request cannot leak the lock and
345            // wedge later requests.
346            tracing::trace!(target: "async_snmp::client", { snmp.bytes = data.len() }, "sending request");
347            match self.inner.transport.request(data, request_id).await {
348                Ok((response_data, _source)) => {
349                    tracing::trace!(target: "async_snmp::client", { snmp.bytes = response_data.len() }, "received response");
350
351                    // Decode response and extract PDU
352                    let response = Message::decode(response_data)?;
353
354                    // Validate response version matches request version
355                    let response_version = response.version();
356                    let expected_version = self.inner.config.version;
357                    if response_version != expected_version {
358                        tracing::warn!(target: "async_snmp::client", { ?expected_version, ?response_version, peer = %self.peer_addr() }, "version mismatch in response");
359                        return Err(Error::MalformedResponse {
360                            target: self.peer_addr(),
361                        }
362                        .boxed());
363                    }
364
365                    // Warn when the community does not echo the one we sent.
366                    // net-snmp accepts such responses (proxies and some
367                    // agents rewrite the community), so this is not a reject.
368                    if let Message::Community(ref m) = response
369                        && m.community != self.inner.config.community
370                    {
371                        tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr() }, "community mismatch in response");
372                    }
373
374                    let Some(response_pdu) = response.into_pdu() else {
375                        tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr() }, "received TrapV1 in response to request");
376                        return Err(Error::MalformedResponse {
377                            target: self.peer_addr(),
378                        }
379                        .boxed());
380                    };
381
382                    // RFC 3416 Section 4.2: only a Response-PDU may answer a
383                    // request; reject echoed request-type PDUs
384                    if response_pdu.pdu_type != PduType::Response {
385                        tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), pdu_type = ?response_pdu.pdu_type }, "non-Response PDU in response");
386                        return Err(Error::MalformedResponse {
387                            target: self.peer_addr(),
388                        }
389                        .boxed());
390                    }
391
392                    // Validate request ID
393                    if response_pdu.request_id != request_id {
394                        tracing::warn!(target: "async_snmp::client", { expected_request_id = request_id, actual_request_id = response_pdu.request_id, peer = %self.peer_addr() }, "request ID mismatch in response");
395                        return Err(Error::MalformedResponse {
396                            target: self.peer_addr(),
397                        }
398                        .boxed());
399                    }
400
401                    // Check for SNMP error
402                    if let Some(err) = pdu_to_snmp_error(&response_pdu, self.peer_addr()) {
403                        Span::current()
404                            .record("snmp.elapsed_ms", start.elapsed().as_millis() as u64);
405                        return Err(err);
406                    }
407
408                    Span::current().record("snmp.elapsed_ms", start.elapsed().as_millis() as u64);
409                    return Ok(response_pdu);
410                }
411                Err(e) if matches!(*e, Error::Timeout { .. }) => {
412                    last_error = Some(e);
413                    // Apply backoff delay before next retry (if not last attempt)
414                    if attempt < max_attempts {
415                        let delay = self.inner.config.retry.compute_delay(attempt);
416                        if !delay.is_zero() {
417                            tracing::debug!(target: "async_snmp::client", { delay_ms = delay.as_millis() as u64 }, "backing off");
418                            tokio::time::sleep(delay).await;
419                        }
420                    }
421                    // fall thru to next loop iteration
422                }
423                Err(e) => {
424                    Span::current().record("snmp.elapsed_ms", start.elapsed().as_millis() as u64);
425                    return Err(e);
426                }
427            }
428        }
429
430        // All retries exhausted. Every failing attempt was a timeout (other
431        // errors return early), so build the final error here with the true
432        // total elapsed time and retry count rather than propagating the
433        // per-attempt transport timeout, whose elapsed/retries are not
434        // meaningful at this layer.
435        let _ = last_error;
436        let elapsed = start.elapsed();
437        Span::current().record("snmp.elapsed_ms", elapsed.as_millis() as u64);
438        tracing::debug!(target: "async_snmp::client", { request_id, peer = %self.peer_addr(), ?elapsed, retries = max_attempts }, "request timed out");
439        Err(Error::Timeout {
440            target: self.peer_addr(),
441            elapsed,
442            retries: max_attempts,
443        }
444        .boxed())
445    }
446
447    /// Send a standard request (GET, GETNEXT, SET) and wait for response.
448    async fn send_request(&self, pdu: Pdu) -> Result<Pdu> {
449        // Dispatch to V3 handler if configured
450        if self.is_v3() {
451            return self.send_v3_and_recv(pdu).await;
452        }
453
454        tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = ?pdu.pdu_type, snmp.varbind_count = pdu.varbinds.len() }, "sending {} request", pdu.pdu_type);
455
456        let request_id = pdu.request_id;
457        let message = CommunityMessage::new(
458            self.inner.config.version,
459            self.inner.config.community.clone(),
460            pdu,
461        );
462        let data = message.encode();
463        let response = self.send_and_recv(request_id, &data).await?;
464
465        tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = ?response.pdu_type, snmp.varbind_count = response.varbinds.len(), snmp.error_status = response.error_status, snmp.error_index = response.error_index }, "received {} response", response.pdu_type);
466
467        Ok(response)
468    }
469
470    /// Send a GETBULK request and wait for response.
471    async fn send_bulk_request(&self, pdu: GetBulkPdu) -> Result<Pdu> {
472        // Dispatch to V3 handler if configured
473        if self.is_v3() {
474            // Convert GetBulkPdu to Pdu for V3 encoding
475            let pdu = Pdu::get_bulk(
476                pdu.request_id,
477                pdu.non_repeaters,
478                pdu.max_repetitions,
479                pdu.varbinds,
480            );
481            return self.send_v3_and_recv(pdu).await;
482        }
483
484        tracing::debug!(target: "async_snmp::client", { snmp.non_repeaters = pdu.non_repeaters, snmp.max_repetitions = pdu.max_repetitions, snmp.varbind_count = pdu.varbinds.len() }, "sending GetBulkRequest");
485
486        let request_id = pdu.request_id;
487        let data = CommunityMessage::encode_bulk(
488            self.inner.config.version,
489            self.inner.config.community.clone(),
490            &pdu,
491        );
492        let response = self.send_and_recv(request_id, &data).await?;
493
494        tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = ?response.pdu_type, snmp.varbind_count = response.varbinds.len(), snmp.error_status = response.error_status, snmp.error_index = response.error_index }, "received {} response", response.pdu_type);
495
496        Ok(response)
497    }
498
499    /// GET a single OID.
500    #[instrument(skip(self), err, fields(snmp.target = %self.peer_addr(), snmp.oid = %oid))]
501    pub async fn get(&self, oid: &Oid) -> Result<VarBind> {
502        let request_id = self.next_request_id();
503        let pdu = Pdu::get_request(request_id, std::slice::from_ref(oid));
504        let response = self.send_request(pdu).await?;
505
506        response.varbinds.into_iter().next().ok_or_else(|| {
507            tracing::debug!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %DecodeErrorKind::EmptyResponse }, "empty GET response");
508            Error::MalformedResponse {
509                target: self.peer_addr(),
510            }
511            .boxed()
512        })
513    }
514
515    /// GET multiple OIDs.
516    ///
517    /// If the OID list exceeds `max_oids_per_request`, the request is
518    /// automatically split into multiple batches. Results are returned
519    /// in the same order as the input OIDs.
520    ///
521    /// # Example
522    ///
523    /// ```rust,no_run
524    /// # use async_snmp::{Auth, Client, oid};
525    /// # async fn example() -> async_snmp::Result<()> {
526    /// # let client = Client::builder("127.0.0.1:161", Auth::v2c("public")).connect().await?;
527    /// let results = client.get_many(&[
528    ///     oid!(1, 3, 6, 1, 2, 1, 1, 1, 0),  // sysDescr
529    ///     oid!(1, 3, 6, 1, 2, 1, 1, 3, 0),  // sysUpTime
530    ///     oid!(1, 3, 6, 1, 2, 1, 1, 5, 0),  // sysName
531    /// ]).await?;
532    /// # Ok(())
533    /// # }
534    /// ```
535    #[instrument(skip(self, oids), err, fields(snmp.target = %self.peer_addr(), snmp.oid_count = oids.len()))]
536    pub async fn get_many(&self, oids: &[Oid]) -> Result<Vec<VarBind>> {
537        self.get_or_getnext_many(oids, "GET", Pdu::get_request)
538            .await
539    }
540
541    /// GETNEXT for a single OID.
542    #[instrument(skip(self), err, fields(snmp.target = %self.peer_addr(), snmp.oid = %oid))]
543    pub async fn get_next(&self, oid: &Oid) -> Result<VarBind> {
544        let request_id = self.next_request_id();
545        let pdu = Pdu::get_next_request(request_id, std::slice::from_ref(oid));
546        let response = self.send_request(pdu).await?;
547
548        response.varbinds.into_iter().next().ok_or_else(|| {
549            tracing::debug!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %DecodeErrorKind::EmptyResponse }, "empty GETNEXT response");
550            Error::MalformedResponse {
551                target: self.peer_addr(),
552            }
553            .boxed()
554        })
555    }
556
557    /// GETNEXT for multiple OIDs.
558    ///
559    /// If the OID list exceeds `max_oids_per_request`, the request is
560    /// automatically split into multiple batches. Results are returned
561    /// in the same order as the input OIDs.
562    ///
563    /// # Example
564    ///
565    /// ```rust,no_run
566    /// # use async_snmp::{Auth, Client, oid};
567    /// # async fn example() -> async_snmp::Result<()> {
568    /// # let client = Client::builder("127.0.0.1:161", Auth::v2c("public")).connect().await?;
569    /// let results = client.get_next_many(&[
570    ///     oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2),  // ifDescr
571    ///     oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 3),  // ifType
572    /// ]).await?;
573    /// # Ok(())
574    /// # }
575    /// ```
576    #[instrument(skip(self, oids), err, fields(snmp.target = %self.peer_addr(), snmp.oid_count = oids.len()))]
577    pub async fn get_next_many(&self, oids: &[Oid]) -> Result<Vec<VarBind>> {
578        self.get_or_getnext_many(oids, "GETNEXT", Pdu::get_next_request)
579            .await
580    }
581
582    /// Shared implementation for GET-many and GETNEXT-many.
583    ///
584    /// `op` is the PDU constructor (`Pdu::get_request` or `Pdu::get_next_request`).
585    /// `op_name` is used only for log messages.
586    async fn get_or_getnext_many(
587        &self,
588        oids: &[Oid],
589        op_name: &'static str,
590        op: fn(i32, &[Oid]) -> Pdu,
591    ) -> Result<Vec<VarBind>> {
592        if oids.is_empty() {
593            return Ok(Vec::new());
594        }
595
596        let max_per_request = self.inner.config.max_oids_per_request;
597        let mut all_results = Vec::with_capacity(oids.len());
598
599        for chunk in oids.chunks(max_per_request) {
600            self.send_batch_with_bisect(chunk, op_name, op, &mut all_results)
601                .await?;
602        }
603
604        Ok(all_results)
605    }
606
607    /// Send a batch of OIDs, automatically bisecting on tooBig errors.
608    ///
609    /// If the agent returns tooBig for a batch with more than one OID, the batch
610    /// is split in half and each half is retried. This repeats recursively until
611    /// batches succeed or a single-OID request fails (which is unrecoverable).
612    fn send_batch_with_bisect<'a>(
613        &'a self,
614        oids: &'a [Oid],
615        op_name: &'static str,
616        op: fn(i32, &[Oid]) -> Pdu,
617        results: &'a mut Vec<VarBind>,
618    ) -> Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
619        Box::pin(async move {
620            let request_id = self.next_request_id();
621            let pdu = op(request_id, oids);
622            match self.send_request(pdu).await {
623                Ok(response) => {
624                    if response.varbinds.len() > oids.len() {
625                        tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), expected = oids.len(), actual = response.varbinds.len(), snmp.op = op_name }, "response has more varbinds than requested");
626                        return Err(Error::MalformedResponse {
627                            target: self.peer_addr(),
628                        }
629                        .boxed());
630                    } else if response.varbinds.len() < oids.len() {
631                        tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), expected = oids.len(), actual = response.varbinds.len(), snmp.op = op_name }, "response has fewer varbinds than requested");
632                        return Err(Error::MalformedResponse {
633                            target: self.peer_addr(),
634                        }
635                        .boxed());
636                    }
637                    results.extend(response.varbinds);
638                    Ok(())
639                }
640                Err(e)
641                    if oids.len() > 1
642                        && matches!(
643                            &*e,
644                            Error::Snmp {
645                                status: ErrorStatus::TooBig,
646                                ..
647                            }
648                        ) =>
649                {
650                    let mid = oids.len() / 2;
651                    tracing::debug!(target: "async_snmp::client", { peer = %self.peer_addr(), snmp.batch_size = oids.len(), snmp.split_at = mid, snmp.op = op_name }, "tooBig response, bisecting batch");
652                    self.send_batch_with_bisect(&oids[..mid], op_name, op, results)
653                        .await?;
654                    self.send_batch_with_bisect(&oids[mid..], op_name, op, results)
655                        .await?;
656                    Ok(())
657                }
658                Err(e) => Err(e),
659            }
660        })
661    }
662
663    /// SET a single OID.
664    #[instrument(skip(self, value), err, fields(snmp.target = %self.peer_addr(), snmp.oid = %oid))]
665    pub async fn set(&self, oid: &Oid, value: Value) -> Result<VarBind> {
666        let request_id = self.next_request_id();
667        let varbind = VarBind::new(oid.clone(), value);
668        let pdu = Pdu::set_request(request_id, vec![varbind]);
669        let response = self.send_request(pdu).await?;
670
671        response.varbinds.into_iter().next().ok_or_else(|| {
672            tracing::debug!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %DecodeErrorKind::EmptyResponse }, "empty SET response");
673            Error::MalformedResponse {
674                target: self.peer_addr(),
675            }
676            .boxed()
677        })
678    }
679
680    /// SET multiple OIDs in a single atomic PDU.
681    ///
682    /// RFC 3416 requires that a SET request be atomic: either all variables
683    /// in the request are set, or none are. To preserve this guarantee,
684    /// `set_many` refuses to split the varbind list across multiple PDUs.
685    ///
686    /// If `varbinds.len()` exceeds `max_oids_per_request`, this method
687    /// returns `Error::Config` rather than silently batching the request.
688    /// Callers that need to set more variables than the per-request limit
689    /// must issue multiple explicit `set_many` calls and handle partial
690    /// failure themselves.
691    ///
692    /// # Example
693    ///
694    /// ```rust,no_run
695    /// # use async_snmp::{Auth, Client, oid, Value};
696    /// # async fn example() -> async_snmp::Result<()> {
697    /// # let client = Client::builder("127.0.0.1:161", Auth::v2c("private")).connect().await?;
698    /// let results = client.set_many(&[
699    ///     (oid!(1, 3, 6, 1, 2, 1, 1, 5, 0), Value::from("new-hostname")),
700    ///     (oid!(1, 3, 6, 1, 2, 1, 1, 6, 0), Value::from("new-location")),
701    /// ]).await?;
702    /// # Ok(())
703    /// # }
704    /// ```
705    #[instrument(skip(self, varbinds), err, fields(snmp.target = %self.peer_addr(), snmp.oid_count = varbinds.len()))]
706    pub async fn set_many(&self, varbinds: &[(Oid, Value)]) -> Result<Vec<VarBind>> {
707        if varbinds.is_empty() {
708            return Ok(Vec::new());
709        }
710
711        let max_per_request = self.inner.config.max_oids_per_request;
712
713        if varbinds.len() > max_per_request {
714            return Err(Error::Config(
715                format!(
716                    "set_many: {} varbinds exceeds max_oids_per_request ({}); \
717                     SET must be atomic and cannot be split across PDUs",
718                    varbinds.len(),
719                    max_per_request,
720                )
721                .into(),
722            )
723            .boxed());
724        }
725
726        let request_id = self.next_request_id();
727        let vbs: Vec<VarBind> = varbinds
728            .iter()
729            .map(|(oid, value)| VarBind::new(oid.clone(), value.clone()))
730            .collect();
731        let expected_count = vbs.len();
732        let pdu = Pdu::set_request(request_id, vbs);
733        let response = self.send_request(pdu).await?;
734        if response.varbinds.len() > expected_count {
735            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), expected = expected_count, actual = response.varbinds.len() }, "SET response has more varbinds than requested");
736            return Err(Error::MalformedResponse {
737                target: self.peer_addr(),
738            }
739            .boxed());
740        } else if response.varbinds.len() < expected_count {
741            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), expected = expected_count, actual = response.varbinds.len() }, "SET response has fewer varbinds than requested");
742        }
743        Ok(response.varbinds)
744    }
745
746    /// Send a trap (fire-and-forget).
747    ///
748    /// For V1 clients: constructs a `TrapV1` PDU. The `trap_oid` is reverse-mapped
749    /// to v1 `generic_trap/specific_trap/enterprise` fields per RFC 3584 Section 3.2.
750    /// The `agent_addr` is set from the transport's local IPv4 address, or `[0,0,0,0]`
751    /// if the local address is IPv6. Use [`send_v1_trap`](Self::send_v1_trap) for
752    /// explicit control over v1 fields.
753    ///
754    /// For V2c/V3 clients: constructs a `TrapV2` PDU with the mandatory sysUpTime.0
755    /// and snmpTrapOID.0 prefix.
756    ///
757    /// For V3: uses the persisted local authoritative engine state configured
758    /// through `ClientBuilder::local_authoritative_engine`.
759    ///
760    /// # Arguments
761    ///
762    /// * `trap_oid` - The trap OID (snmpTrapOID.0 value)
763    /// * `uptime` - sysUpTime.0 value in hundredths of seconds
764    /// * `varbinds` - Additional variable bindings (appended after the prefix)
765    #[instrument(skip(self, varbinds), err, fields(snmp.target = %self.peer_addr(), snmp.trap_oid = %trap_oid))]
766    pub async fn send_trap(
767        &self,
768        trap_oid: &Oid,
769        uptime: u32,
770        varbinds: Vec<VarBind>,
771    ) -> Result<()> {
772        if self.inner.config.version == Version::V1 {
773            // Build a v2-style PDU and convert to v1.
774            // Per RFC 3584 Section 3, use the local IPv4 address as agent_addr.
775            let local_ip = match self.inner.transport.local_addr().ip() {
776                std::net::IpAddr::V4(v4) => v4.octets(),
777                std::net::IpAddr::V6(_) => [0, 0, 0, 0],
778            };
779            // request_id is unused in the v1 wire format, use 0 to avoid
780            // wasting a slot in the request_id sequence.
781            let pdu = Pdu::trap_v2(0, uptime, trap_oid, varbinds);
782            let trap = pdu.to_v1_trap(local_ip).ok_or_else(|| {
783                Error::Config("cannot convert trap to v1 (Counter64 varbind?)".into()).boxed()
784            })?;
785            return self.send_v1_trap(trap).await;
786        }
787
788        let request_id = self.next_request_id();
789        let pdu = Pdu::trap_v2(request_id, uptime, trap_oid, varbinds);
790
791        if self.is_v3() {
792            self.ensure_local_keys_derived()?;
793            let msg_id = self.next_request_id();
794            let data = self.build_v3_trap_message(&pdu, msg_id)?;
795            tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = "TrapV2", snmp.varbind_count = pdu.varbinds.len(), snmp.bytes = data.len() }, "sending V3 trap");
796            self.inner.transport.send(&data).await?;
797        } else {
798            let message = CommunityMessage::new(
799                self.inner.config.version,
800                self.inner.config.community.clone(),
801                pdu,
802            );
803            let data = message.encode();
804            tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = "TrapV2", snmp.bytes = data.len() }, "sending v2c trap");
805            self.inner.transport.send(&data).await?;
806        }
807
808        Ok(())
809    }
810
811    /// Send an `SNMPv1` trap with explicit v1 PDU fields.
812    ///
813    /// This is a lower-level method that accepts a pre-built [`TrapV1Pdu`],
814    /// giving full control over enterprise OID, `agent_addr`, `generic_trap`,
815    /// `specific_trap`, and `time_stamp` fields.
816    ///
817    /// The client must be configured for V1 (`Auth::v1()`). Returns an error
818    /// if the client version is not V1.
819    ///
820    /// # Example
821    ///
822    /// ```rust,no_run
823    /// # use async_snmp::{Auth, Client, TrapV1Pdu, GenericTrap, oid};
824    /// # async fn example() -> async_snmp::Result<()> {
825    /// let client = Client::builder("192.168.1.100:162", Auth::v1("public"))
826    ///     .connect().await?;
827    ///
828    /// let trap = TrapV1Pdu::new(
829    ///     oid!(1, 3, 6, 1, 4, 1, 9999),  // enterprise
830    ///     [192, 168, 1, 1],               // agent address
831    ///     GenericTrap::ColdStart,
832    ///     0,
833    ///     12345,                          // uptime in centiseconds
834    ///     vec![],
835    /// );
836    /// client.send_v1_trap(trap).await?;
837    /// # Ok(())
838    /// # }
839    /// ```
840    #[instrument(skip(self, trap), err, fields(snmp.target = %self.peer_addr(), snmp.generic_trap = %trap.generic_trap))]
841    pub async fn send_v1_trap(&self, trap: TrapV1Pdu) -> Result<()> {
842        if self.inner.config.version != Version::V1 {
843            return Err(Error::Config("send_v1_trap requires a V1 client".into()).boxed());
844        }
845
846        let message = CommunityMessage::v1_trap(self.inner.config.community.clone(), trap);
847        let data = message.encode();
848        tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = "TrapV1", snmp.bytes = data.len() }, "sending v1 trap");
849        self.inner.transport.send(&data).await?;
850
851        Ok(())
852    }
853
854    /// Send a v2c/v3 inform and wait for acknowledgement.
855    ///
856    /// Constructs an `InformRequest` PDU with the mandatory sysUpTime.0 and
857    /// snmpTrapOID.0 prefix, sends it to the target, and waits for a Response
858    /// PDU. Uses the same retry and timeout logic as other request types.
859    ///
860    /// For V3: uses engine discovery against the receiver (same as GET/SET).
861    /// V1 is not supported and returns an error.
862    ///
863    /// # Arguments
864    ///
865    /// * `trap_oid` - The trap OID (snmpTrapOID.0 value)
866    /// * `uptime` - sysUpTime.0 value in hundredths of seconds
867    /// * `varbinds` - Additional variable bindings (appended after the prefix)
868    #[instrument(skip(self, varbinds), err, fields(snmp.target = %self.peer_addr(), snmp.trap_oid = %trap_oid))]
869    pub async fn send_inform(
870        &self,
871        trap_oid: &Oid,
872        uptime: u32,
873        varbinds: Vec<VarBind>,
874    ) -> Result<()> {
875        if self.inner.config.version == Version::V1 {
876            return Err(Error::Config("v1 inform sending not supported".into()).boxed());
877        }
878
879        let request_id = self.next_request_id();
880        let pdu = Pdu::inform_request(request_id, uptime, trap_oid, varbinds);
881        let _response = self.send_request(pdu).await?;
882        Ok(())
883    }
884
885    /// GETBULK request (SNMPv2c/v3 only).
886    ///
887    /// Efficiently retrieves multiple variable bindings in a single request.
888    /// GETBULK splits the requested OIDs into two groups:
889    ///
890    /// - **Non-repeaters** (first N OIDs): Each gets a single GETNEXT, returning
891    ///   one value per OID. Use for scalar values like `sysUpTime.0`.
892    /// - **Repeaters** (remaining OIDs): Each gets up to `max_repetitions` GETNEXTs,
893    ///   returning multiple values per OID. Use for walking table columns.
894    ///
895    /// # Arguments
896    ///
897    /// * `oids` - OIDs to retrieve
898    /// * `non_repeaters` - How many OIDs (from the start) are non-repeating
899    /// * `max_repetitions` - Maximum rows to return for each repeating OID
900    ///
901    /// # Example
902    ///
903    /// ```rust,no_run
904    /// # use async_snmp::{Auth, Client, oid};
905    /// # async fn example() -> async_snmp::Result<()> {
906    /// # let client = Client::builder("127.0.0.1:161", Auth::v2c("public")).connect().await?;
907    /// // Get sysUpTime (non-repeater) plus 10 interface descriptions (repeater)
908    /// let results = client.get_bulk(
909    ///     &[oid!(1, 3, 6, 1, 2, 1, 1, 3, 0), oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2)],
910    ///     1,  // first OID is non-repeating
911    ///     10, // get up to 10 values for the second OID
912    /// ).await?;
913    /// // Results: [sysUpTime value, ifDescr.1, ifDescr.2, ..., ifDescr.10]
914    /// # Ok(())
915    /// # }
916    /// ```
917    #[instrument(skip(self, oids), err, fields(
918        snmp.target = %self.peer_addr(),
919        snmp.oid_count = oids.len(),
920        snmp.non_repeaters = non_repeaters,
921        snmp.max_repetitions = max_repetitions
922    ))]
923    pub async fn get_bulk(
924        &self,
925        oids: &[Oid],
926        non_repeaters: i32,
927        max_repetitions: i32,
928    ) -> Result<Vec<VarBind>> {
929        let request_id = self.next_request_id();
930        let pdu = GetBulkPdu::new(request_id, non_repeaters, max_repetitions, oids);
931        let response = self.send_bulk_request(pdu).await?;
932        Ok(response.varbinds)
933    }
934
935    /// Walk an OID subtree.
936    ///
937    /// Auto-selects the optimal walk method based on SNMP version and `WalkMode`:
938    /// - `WalkMode::Auto` (default): Uses GETNEXT for V1, GETBULK for V2c/V3
939    /// - `WalkMode::GetNext`: Always uses GETNEXT
940    /// - `WalkMode::GetBulk`: Always uses GETBULK (fails on V1)
941    ///
942    /// Returns an async stream that yields each variable binding in the subtree.
943    /// The walk terminates when an OID outside the subtree is encountered or
944    /// when `EndOfMibView` is returned.
945    ///
946    /// Uses the client's configured `oid_ordering`, `max_walk_results`, and
947    /// `max_repetitions` (for GETBULK) settings.
948    ///
949    /// # Example
950    ///
951    /// ```rust,no_run
952    /// # use async_snmp::{Auth, Client, oid};
953    /// # async fn example() -> async_snmp::Result<()> {
954    /// # let client = Client::builder("127.0.0.1:161", Auth::v2c("public")).connect().await?;
955    /// // Auto-selects GETBULK for V2c/V3, GETNEXT for V1
956    /// let results = client.walk(oid!(1, 3, 6, 1, 2, 1, 1))?.collect().await?;
957    /// # Ok(())
958    /// # }
959    /// ```
960    #[instrument(skip(self), fields(snmp.target = %self.peer_addr(), snmp.oid = %oid))]
961    pub fn walk(&self, oid: Oid) -> Result<WalkStream<T>>
962    where
963        T: 'static,
964    {
965        let ordering = self.inner.config.oid_ordering;
966        let max_results = self.inner.config.max_walk_results;
967        let walk_mode = self.inner.config.walk_mode;
968        let max_repetitions = max_repetitions_to_wire(self.inner.config.max_repetitions);
969        let version = self.inner.config.version;
970
971        WalkStream::new(
972            self.clone(),
973            oid,
974            version,
975            walk_mode,
976            ordering,
977            max_results,
978            max_repetitions,
979        )
980    }
981
982    /// Walk an OID subtree using GETNEXT.
983    ///
984    /// This method always uses GETNEXT regardless of the client's `WalkMode` configuration.
985    /// For auto-selection based on version and mode, use [`walk()`](Self::walk) instead.
986    ///
987    /// Returns an async stream that yields each variable binding in the subtree.
988    /// The walk terminates when an OID outside the subtree is encountered or
989    /// when `EndOfMibView` is returned.
990    ///
991    /// Uses the client's configured `oid_ordering` and `max_walk_results` settings.
992    ///
993    /// # Example
994    ///
995    /// ```rust,no_run
996    /// # use async_snmp::{Auth, Client, oid};
997    /// # async fn example() -> async_snmp::Result<()> {
998    /// # let client = Client::builder("127.0.0.1:161", Auth::v2c("public")).connect().await?;
999    /// // Force GETNEXT even for V2c/V3 clients
1000    /// let results = client.walk_getnext(oid!(1, 3, 6, 1, 2, 1, 1)).collect().await?;
1001    /// # Ok(())
1002    /// # }
1003    /// ```
1004    #[instrument(skip(self), fields(snmp.target = %self.peer_addr(), snmp.oid = %oid))]
1005    pub fn walk_getnext(&self, oid: Oid) -> Walk<T>
1006    where
1007        T: 'static,
1008    {
1009        let ordering = self.inner.config.oid_ordering;
1010        let max_results = self.inner.config.max_walk_results;
1011        Walk::new(self.clone(), oid, ordering, max_results)
1012    }
1013
1014    /// Walk an OID subtree using GETBULK (more efficient than GETNEXT).
1015    ///
1016    /// Returns an async stream that yields each variable binding in the subtree.
1017    /// Uses GETBULK internally with `non_repeaters=0`, fetching `max_repetitions`
1018    /// values per request for efficient table traversal.
1019    ///
1020    /// Uses the client's configured `oid_ordering` and `max_walk_results` settings.
1021    ///
1022    /// # Arguments
1023    ///
1024    /// * `oid` - The base OID of the subtree to walk
1025    /// * `max_repetitions` - How many OIDs to fetch per request
1026    ///
1027    /// # Example
1028    ///
1029    /// ```rust,no_run
1030    /// # use async_snmp::{Auth, Client, oid};
1031    /// # async fn example() -> async_snmp::Result<()> {
1032    /// # let client = Client::builder("127.0.0.1:161", Auth::v2c("public")).connect().await?;
1033    /// // Walk the interfaces table efficiently
1034    /// let walk = client.bulk_walk(oid!(1, 3, 6, 1, 2, 1, 2, 2), 25);
1035    /// // Process with futures StreamExt
1036    /// # Ok(())
1037    /// # }
1038    /// ```
1039    #[instrument(skip(self), fields(snmp.target = %self.peer_addr(), snmp.oid = %oid, snmp.max_repetitions = max_repetitions))]
1040    pub fn bulk_walk(&self, oid: Oid, max_repetitions: i32) -> BulkWalk<T>
1041    where
1042        T: 'static,
1043    {
1044        let ordering = self.inner.config.oid_ordering;
1045        let max_results = self.inner.config.max_walk_results;
1046        BulkWalk::new(self.clone(), oid, max_repetitions, ordering, max_results)
1047    }
1048
1049    /// Walk an OID subtree using the client's configured `max_repetitions`.
1050    ///
1051    /// This is a convenience method that uses the client's `max_repetitions` setting
1052    /// (default: 25) instead of requiring it as a parameter.
1053    ///
1054    /// # Example
1055    ///
1056    /// ```rust,no_run
1057    /// # use async_snmp::{Auth, Client, oid};
1058    /// # async fn example() -> async_snmp::Result<()> {
1059    /// # let client = Client::builder("127.0.0.1:161", Auth::v2c("public")).connect().await?;
1060    /// // Walk using configured max_repetitions
1061    /// let walk = client.bulk_walk_default(oid!(1, 3, 6, 1, 2, 1, 2, 2));
1062    /// // Process with futures StreamExt
1063    /// # Ok(())
1064    /// # }
1065    /// ```
1066    #[instrument(skip(self), fields(snmp.target = %self.peer_addr(), snmp.oid = %oid))]
1067    pub fn bulk_walk_default(&self, oid: Oid) -> BulkWalk<T>
1068    where
1069        T: 'static,
1070    {
1071        let ordering = self.inner.config.oid_ordering;
1072        let max_results = self.inner.config.max_walk_results;
1073        let max_repetitions = max_repetitions_to_wire(self.inner.config.max_repetitions);
1074        BulkWalk::new(self.clone(), oid, max_repetitions, ordering, max_results)
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081    use crate::message::CommunityMessage;
1082    use crate::oid::Oid;
1083    use crate::pdu::{Pdu, PduType};
1084    use crate::varbind::VarBind;
1085    use crate::version::Version;
1086    use bytes::Bytes;
1087    use std::collections::VecDeque;
1088    use std::net::SocketAddr;
1089    use std::sync::{Arc, Mutex};
1090
1091    // -------------------------------------------------------------------------
1092    // max_repetitions_to_wire: saturating u32 -> i32 conversion used by
1093    // `walk()` and `bulk_walk_default()` so a configured `max_repetitions`
1094    // above `i32::MAX` cannot wrap to a negative GETBULK field (C7).
1095    // -------------------------------------------------------------------------
1096
1097    #[test]
1098    fn test_max_repetitions_to_wire_saturates_above_i32_max() {
1099        // A plain `as i32` cast would wrap this to a negative number.
1100        assert_eq!(max_repetitions_to_wire(u32::MAX), i32::MAX);
1101        assert_eq!(
1102            max_repetitions_to_wire(i32::MAX as u32 + 1),
1103            i32::MAX,
1104            "one past i32::MAX must saturate, not wrap negative"
1105        );
1106    }
1107
1108    #[test]
1109    fn test_max_repetitions_to_wire_passes_through_in_range_values() {
1110        assert_eq!(max_repetitions_to_wire(0), 0);
1111        assert_eq!(max_repetitions_to_wire(25), 25);
1112        assert_eq!(max_repetitions_to_wire(i32::MAX as u32), i32::MAX);
1113    }
1114
1115    // -------------------------------------------------------------------------
1116    // Mock transport that returns a response with a configurable number of
1117    // varbinds, regardless of how many were requested.
1118    // -------------------------------------------------------------------------
1119
1120    #[derive(Clone)]
1121    struct TruncatingTransport {
1122        /// Number of varbinds to include in each response.
1123        response_varbind_count: usize,
1124        /// Captured (`request_id`) values from sent requests, stored for building
1125        /// responses.
1126        pending: Arc<Mutex<VecDeque<i32>>>,
1127    }
1128
1129    impl TruncatingTransport {
1130        fn new(response_varbind_count: usize) -> Self {
1131            Self {
1132                response_varbind_count,
1133                pending: Arc::new(Mutex::new(VecDeque::new())),
1134            }
1135        }
1136    }
1137
1138    impl Transport for TruncatingTransport {
1139        fn send(&self, data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send {
1140            // Decode the sent request to extract the request_id.
1141            let request_id = crate::transport::extract_request_id(data).unwrap_or(1);
1142            {
1143                let mut q = self.pending.lock().unwrap();
1144                q.push_back(request_id);
1145            }
1146            async { Ok(()) }
1147        }
1148
1149        fn recv(
1150            &self,
1151            _request_id: i32,
1152        ) -> impl std::future::Future<Output = Result<(Bytes, SocketAddr)>> + Send {
1153            let request_id = {
1154                let mut q = self.pending.lock().unwrap();
1155                q.pop_front().unwrap_or(1)
1156            };
1157            let n = self.response_varbind_count;
1158            let peer: SocketAddr = "127.0.0.1:161".parse().unwrap();
1159
1160            async move {
1161                // Build a response PDU with n varbinds (NULL values).
1162                let varbinds: Vec<VarBind> = (0..n)
1163                    .map(|i| {
1164                        VarBind::new(
1165                            Oid::from_slice(&[1, 3, 6, 1, i as u32]),
1166                            crate::value::Value::Null,
1167                        )
1168                    })
1169                    .collect();
1170
1171                let pdu = Pdu {
1172                    pdu_type: PduType::Response,
1173                    request_id,
1174                    error_status: 0,
1175                    error_index: 0,
1176                    varbinds,
1177                };
1178
1179                let msg = CommunityMessage::v2c(Bytes::from_static(b"public"), pdu);
1180                let encoded = msg.encode();
1181                Ok((encoded, peer))
1182            }
1183        }
1184
1185        fn peer_addr(&self) -> SocketAddr {
1186            "127.0.0.1:161".parse().unwrap()
1187        }
1188
1189        fn local_addr(&self) -> SocketAddr {
1190            "127.0.0.1:0".parse().unwrap()
1191        }
1192
1193        fn is_reliable(&self) -> bool {
1194            true
1195        }
1196    }
1197
1198    fn make_client(response_varbind_count: usize) -> Client<TruncatingTransport> {
1199        let transport = TruncatingTransport::new(response_varbind_count);
1200        let config = ClientConfig {
1201            version: Version::V2c,
1202            max_oids_per_request: 10,
1203            retry: crate::client::retry::Retry::none(),
1204            ..Default::default()
1205        };
1206        Client::new(transport, config)
1207    }
1208
1209    #[tokio::test]
1210    async fn new_clamps_zero_max_oids_per_request() {
1211        // Client::new bypasses builder validation; a raw config with
1212        // max_oids_per_request == 0 must be clamped so slice::chunks(0) is
1213        // never reached (which would panic).
1214        let transport = TruncatingTransport::new(3);
1215        let config = ClientConfig {
1216            version: Version::V2c,
1217            max_oids_per_request: 0,
1218            retry: crate::client::retry::Retry::none(),
1219            ..Default::default()
1220        };
1221        let client = Client::new(transport, config);
1222        assert_eq!(
1223            client.inner.config.max_oids_per_request,
1224            DEFAULT_MAX_OIDS_PER_REQUEST
1225        );
1226
1227        let oids = [
1228            Oid::from_slice(&[1, 3, 6, 1, 1]),
1229            Oid::from_slice(&[1, 3, 6, 1, 2]),
1230            Oid::from_slice(&[1, 3, 6, 1, 3]),
1231        ];
1232        // Must not panic on chunks(0).
1233        let result = client.get_many(&oids).await;
1234        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1235    }
1236
1237    #[tokio::test]
1238    async fn get_many_rejects_truncated_response() {
1239        // Request 3 OIDs but the mock returns only 1 varbind - an under-count breaks
1240        // positional correspondence and must be rejected (RFC 3416 4.2.1).
1241        let client = make_client(1);
1242        let oids = [
1243            Oid::from_slice(&[1, 3, 6, 1, 1]),
1244            Oid::from_slice(&[1, 3, 6, 1, 2]),
1245            Oid::from_slice(&[1, 3, 6, 1, 3]),
1246        ];
1247
1248        let err = client.get_many(&oids).await.unwrap_err();
1249        assert!(
1250            matches!(*err, Error::MalformedResponse { .. }),
1251            "expected MalformedResponse, got: {err}"
1252        );
1253    }
1254
1255    #[tokio::test]
1256    async fn get_many_rejects_inflated_response() {
1257        // Request 3 OIDs but the mock returns 5 varbinds.
1258        let client = make_client(5);
1259        let oids = [
1260            Oid::from_slice(&[1, 3, 6, 1, 1]),
1261            Oid::from_slice(&[1, 3, 6, 1, 2]),
1262            Oid::from_slice(&[1, 3, 6, 1, 3]),
1263        ];
1264
1265        let err = client.get_many(&oids).await.unwrap_err();
1266        assert!(
1267            matches!(*err, Error::MalformedResponse { .. }),
1268            "expected MalformedResponse, got: {err}"
1269        );
1270    }
1271
1272    #[tokio::test]
1273    async fn get_many_accepts_correct_response_count() {
1274        // Request 3 OIDs and the mock returns exactly 3 varbinds.
1275        let client = make_client(3);
1276        let oids = [
1277            Oid::from_slice(&[1, 3, 6, 1, 1]),
1278            Oid::from_slice(&[1, 3, 6, 1, 2]),
1279            Oid::from_slice(&[1, 3, 6, 1, 3]),
1280        ];
1281
1282        let result = client.get_many(&oids).await;
1283        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1284        assert_eq!(result.unwrap().len(), 3);
1285    }
1286
1287    #[tokio::test]
1288    async fn get_next_many_rejects_truncated_response() {
1289        // Request 3 OIDs but the mock returns only 1 varbind - an under-count breaks
1290        // positional correspondence and must be rejected (RFC 3416 4.2.1).
1291        let client = make_client(1);
1292        let oids = [
1293            Oid::from_slice(&[1, 3, 6, 1, 1]),
1294            Oid::from_slice(&[1, 3, 6, 1, 2]),
1295            Oid::from_slice(&[1, 3, 6, 1, 3]),
1296        ];
1297
1298        let err = client.get_next_many(&oids).await.unwrap_err();
1299        assert!(
1300            matches!(*err, Error::MalformedResponse { .. }),
1301            "expected MalformedResponse, got: {err}"
1302        );
1303    }
1304
1305    #[tokio::test]
1306    async fn get_next_many_rejects_inflated_response() {
1307        // Request 3 OIDs but the mock returns 5 varbinds.
1308        let client = make_client(5);
1309        let oids = [
1310            Oid::from_slice(&[1, 3, 6, 1, 1]),
1311            Oid::from_slice(&[1, 3, 6, 1, 2]),
1312            Oid::from_slice(&[1, 3, 6, 1, 3]),
1313        ];
1314
1315        let err = client.get_next_many(&oids).await.unwrap_err();
1316        assert!(
1317            matches!(*err, Error::MalformedResponse { .. }),
1318            "expected MalformedResponse, got: {err}"
1319        );
1320    }
1321
1322    #[tokio::test]
1323    async fn get_next_many_accepts_correct_response_count() {
1324        // Request 3 OIDs and the mock returns exactly 3 varbinds.
1325        let client = make_client(3);
1326        let oids = [
1327            Oid::from_slice(&[1, 3, 6, 1, 1]),
1328            Oid::from_slice(&[1, 3, 6, 1, 2]),
1329            Oid::from_slice(&[1, 3, 6, 1, 3]),
1330        ];
1331
1332        let result = client.get_next_many(&oids).await;
1333        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1334        assert_eq!(result.unwrap().len(), 3);
1335    }
1336
1337    #[tokio::test]
1338    async fn set_many_warns_on_truncated_response() {
1339        // Request 3 varbinds but the mock returns only 1 - should warn and return what we got.
1340        let client = make_client(1);
1341        let varbinds = [
1342            (
1343                Oid::from_slice(&[1, 3, 6, 1, 1]),
1344                crate::value::Value::Integer(1),
1345            ),
1346            (
1347                Oid::from_slice(&[1, 3, 6, 1, 2]),
1348                crate::value::Value::Integer(2),
1349            ),
1350            (
1351                Oid::from_slice(&[1, 3, 6, 1, 3]),
1352                crate::value::Value::Integer(3),
1353            ),
1354        ];
1355
1356        let result = client.set_many(&varbinds).await;
1357        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1358        assert_eq!(result.unwrap().len(), 1);
1359    }
1360
1361    #[tokio::test]
1362    async fn set_many_rejects_inflated_response() {
1363        // Request 3 varbinds but the mock returns 5.
1364        let client = make_client(5);
1365        let varbinds = [
1366            (
1367                Oid::from_slice(&[1, 3, 6, 1, 1]),
1368                crate::value::Value::Integer(1),
1369            ),
1370            (
1371                Oid::from_slice(&[1, 3, 6, 1, 2]),
1372                crate::value::Value::Integer(2),
1373            ),
1374            (
1375                Oid::from_slice(&[1, 3, 6, 1, 3]),
1376                crate::value::Value::Integer(3),
1377            ),
1378        ];
1379
1380        let err = client.set_many(&varbinds).await.unwrap_err();
1381        assert!(
1382            matches!(*err, Error::MalformedResponse { .. }),
1383            "expected MalformedResponse, got: {err}"
1384        );
1385    }
1386
1387    #[tokio::test]
1388    async fn set_many_accepts_correct_response_count() {
1389        // Request 3 varbinds and the mock returns exactly 3.
1390        let client = make_client(3);
1391        let varbinds = [
1392            (
1393                Oid::from_slice(&[1, 3, 6, 1, 1]),
1394                crate::value::Value::Integer(1),
1395            ),
1396            (
1397                Oid::from_slice(&[1, 3, 6, 1, 2]),
1398                crate::value::Value::Integer(2),
1399            ),
1400            (
1401                Oid::from_slice(&[1, 3, 6, 1, 3]),
1402                crate::value::Value::Integer(3),
1403            ),
1404        ];
1405
1406        let result = client.set_many(&varbinds).await;
1407        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1408        assert_eq!(result.unwrap().len(), 3);
1409    }
1410
1411    // -------------------------------------------------------------------------
1412    // Mock transport that returns tooBig when request exceeds a varbind threshold.
1413    // -------------------------------------------------------------------------
1414
1415    #[derive(Clone)]
1416    struct TooBigTransport {
1417        /// Max varbinds per request before returning tooBig.
1418        max_varbinds: usize,
1419        pending: Arc<Mutex<VecDeque<(i32, usize)>>>,
1420    }
1421
1422    impl TooBigTransport {
1423        fn new(max_varbinds: usize) -> Self {
1424            Self {
1425                max_varbinds,
1426                pending: Arc::new(Mutex::new(VecDeque::new())),
1427            }
1428        }
1429    }
1430
1431    impl Transport for TooBigTransport {
1432        fn send(&self, data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send {
1433            let request_id = crate::transport::extract_request_id(data).unwrap_or(1);
1434            // Decode the message to count varbinds
1435            let msg = CommunityMessage::decode(Bytes::copy_from_slice(data)).unwrap();
1436            let varbind_count = msg.pdu.standard().unwrap().varbinds.len();
1437            {
1438                let mut q = self.pending.lock().unwrap();
1439                q.push_back((request_id, varbind_count));
1440            }
1441            async { Ok(()) }
1442        }
1443
1444        fn recv(
1445            &self,
1446            _request_id: i32,
1447        ) -> impl std::future::Future<Output = Result<(Bytes, SocketAddr)>> + Send {
1448            let (request_id, varbind_count) = {
1449                let mut q = self.pending.lock().unwrap();
1450                q.pop_front().unwrap_or((1, 0))
1451            };
1452            let max = self.max_varbinds;
1453            let peer: SocketAddr = "127.0.0.1:161".parse().unwrap();
1454
1455            async move {
1456                let pdu = if varbind_count > max {
1457                    // Return tooBig with empty varbinds (per RFC 3416)
1458                    Pdu {
1459                        pdu_type: PduType::Response,
1460                        request_id,
1461                        error_status: ErrorStatus::TooBig.as_i32(),
1462                        error_index: 0,
1463                        varbinds: vec![],
1464                    }
1465                } else {
1466                    // Echo back one varbind per requested OID
1467                    let varbinds: Vec<VarBind> = (0..varbind_count)
1468                        .map(|i| {
1469                            VarBind::new(
1470                                Oid::from_slice(&[1, 3, 6, 1, i as u32]),
1471                                crate::value::Value::Integer(i as i32),
1472                            )
1473                        })
1474                        .collect();
1475                    Pdu {
1476                        pdu_type: PduType::Response,
1477                        request_id,
1478                        error_status: 0,
1479                        error_index: 0,
1480                        varbinds,
1481                    }
1482                };
1483
1484                let msg = CommunityMessage::v2c(Bytes::from_static(b"public"), pdu);
1485                Ok((msg.encode(), peer))
1486            }
1487        }
1488
1489        fn peer_addr(&self) -> SocketAddr {
1490            "127.0.0.1:161".parse().unwrap()
1491        }
1492
1493        fn local_addr(&self) -> SocketAddr {
1494            "127.0.0.1:0".parse().unwrap()
1495        }
1496
1497        fn is_reliable(&self) -> bool {
1498            true
1499        }
1500    }
1501
1502    #[tokio::test]
1503    async fn get_many_bisects_on_too_big() {
1504        // Agent can handle at most 3 varbinds per request. We ask for 8.
1505        // With max_oids_per_request=10, the initial batch is all 8 OIDs.
1506        // That triggers tooBig, so it bisects to 4+4, each of which still
1507        // triggers tooBig, then bisects to 2+2+2+2 which all succeed.
1508        let transport = TooBigTransport::new(3);
1509        let config = ClientConfig {
1510            version: Version::V2c,
1511            max_oids_per_request: 10,
1512            retry: crate::client::retry::Retry::none(),
1513            ..Default::default()
1514        };
1515        let client = Client::new(transport, config);
1516
1517        let oids: Vec<Oid> = (0..8u32)
1518            .map(|i| Oid::from_slice(&[1, 3, 6, 1, i]))
1519            .collect();
1520
1521        let result = client.get_many(&oids).await.unwrap();
1522        assert_eq!(result.len(), 8);
1523    }
1524
1525    #[tokio::test]
1526    async fn get_many_single_oid_too_big_is_unrecoverable() {
1527        // Agent returns tooBig even for a single OID - can't bisect further.
1528        let transport = TooBigTransport::new(0);
1529        let config = ClientConfig {
1530            version: Version::V2c,
1531            max_oids_per_request: 10,
1532            retry: crate::client::retry::Retry::none(),
1533            ..Default::default()
1534        };
1535        let client = Client::new(transport, config);
1536
1537        let oids = [Oid::from_slice(&[1, 3, 6, 1, 1])];
1538        let err = client.get_many(&oids).await.unwrap_err();
1539        assert!(
1540            matches!(
1541                &*err,
1542                Error::Snmp {
1543                    status: ErrorStatus::TooBig,
1544                    ..
1545                }
1546            ),
1547            "expected TooBig, got: {err}"
1548        );
1549    }
1550
1551    #[tokio::test]
1552    async fn get_next_many_bisects_on_too_big() {
1553        // Same as get_many test but for GETNEXT.
1554        let transport = TooBigTransport::new(3);
1555        let config = ClientConfig {
1556            version: Version::V2c,
1557            max_oids_per_request: 10,
1558            retry: crate::client::retry::Retry::none(),
1559            ..Default::default()
1560        };
1561        let client = Client::new(transport, config);
1562
1563        let oids: Vec<Oid> = (0..8u32)
1564            .map(|i| Oid::from_slice(&[1, 3, 6, 1, i]))
1565            .collect();
1566
1567        let result = client.get_next_many(&oids).await.unwrap();
1568        assert_eq!(result.len(), 8);
1569    }
1570
1571    // Batched path: get_many with more OIDs than max_per_request.
1572    #[tokio::test]
1573    async fn get_many_batched_rejects_truncated_response() {
1574        // max_oids_per_request = 10, request 12 OIDs, mock returns 1 per batch.
1575        // The first batch under-counts, breaking positional correspondence, and must
1576        // be rejected (RFC 3416 4.2.1).
1577        let transport = TruncatingTransport::new(1);
1578        let config = ClientConfig {
1579            version: Version::V2c,
1580            max_oids_per_request: 10,
1581            retry: crate::client::retry::Retry::none(),
1582            ..Default::default()
1583        };
1584        let client = Client::new(transport, config);
1585
1586        let oids: Vec<Oid> = (0..12u32)
1587            .map(|i| Oid::from_slice(&[1, 3, 6, 1, i]))
1588            .collect();
1589
1590        let err = client.get_many(&oids).await.unwrap_err();
1591        assert!(
1592            matches!(*err, Error::MalformedResponse { .. }),
1593            "expected MalformedResponse, got: {err}"
1594        );
1595    }
1596
1597    #[tokio::test]
1598    async fn get_many_batched_rejects_inflated_response() {
1599        // max_oids_per_request = 10, request 12 OIDs, mock returns 12 per batch.
1600        let transport = TruncatingTransport::new(12);
1601        let config = ClientConfig {
1602            version: Version::V2c,
1603            max_oids_per_request: 10,
1604            retry: crate::client::retry::Retry::none(),
1605            ..Default::default()
1606        };
1607        let client = Client::new(transport, config);
1608
1609        let oids: Vec<Oid> = (0..12u32)
1610            .map(|i| Oid::from_slice(&[1, 3, 6, 1, i]))
1611            .collect();
1612
1613        let err = client.get_many(&oids).await.unwrap_err();
1614        assert!(
1615            matches!(*err, Error::MalformedResponse { .. }),
1616            "expected MalformedResponse, got: {err}"
1617        );
1618    }
1619
1620    // -------------------------------------------------------------------------
1621    // Mock transport returning a response with a configurable PDU type,
1622    // community, and message version, for response-validation tests.
1623    // -------------------------------------------------------------------------
1624
1625    #[derive(Clone)]
1626    struct AdversarialTransport {
1627        pdu_type: PduType,
1628        community: &'static [u8],
1629        respond_as_v1: bool,
1630        pending: Arc<Mutex<VecDeque<i32>>>,
1631    }
1632
1633    impl AdversarialTransport {
1634        fn new(pdu_type: PduType, community: &'static [u8], respond_as_v1: bool) -> Self {
1635            Self {
1636                pdu_type,
1637                community,
1638                respond_as_v1,
1639                pending: Arc::new(Mutex::new(VecDeque::new())),
1640            }
1641        }
1642    }
1643
1644    impl Transport for AdversarialTransport {
1645        fn send(&self, data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send {
1646            let request_id = crate::transport::extract_request_id(data).unwrap_or(1);
1647            self.pending.lock().unwrap().push_back(request_id);
1648            async { Ok(()) }
1649        }
1650
1651        fn recv(
1652            &self,
1653            _request_id: i32,
1654        ) -> impl std::future::Future<Output = Result<(Bytes, SocketAddr)>> + Send {
1655            let request_id = self.pending.lock().unwrap().pop_front().unwrap_or(1);
1656            let peer: SocketAddr = "127.0.0.1:161".parse().unwrap();
1657            let pdu = Pdu {
1658                pdu_type: self.pdu_type,
1659                request_id,
1660                error_status: 0,
1661                error_index: 0,
1662                varbinds: vec![VarBind::new(
1663                    Oid::from_slice(&[1, 3, 6, 1, 1]),
1664                    crate::value::Value::Null,
1665                )],
1666            };
1667            let community = Bytes::from_static(self.community);
1668            let msg = if self.respond_as_v1 {
1669                CommunityMessage::v1(community, pdu)
1670            } else {
1671                CommunityMessage::v2c(community, pdu)
1672            };
1673            let encoded = msg.encode();
1674            async move { Ok((encoded, peer)) }
1675        }
1676
1677        fn peer_addr(&self) -> SocketAddr {
1678            "127.0.0.1:161".parse().unwrap()
1679        }
1680
1681        fn local_addr(&self) -> SocketAddr {
1682            "127.0.0.1:0".parse().unwrap()
1683        }
1684
1685        fn is_reliable(&self) -> bool {
1686            true
1687        }
1688    }
1689
1690    fn adversarial_client(
1691        pdu_type: PduType,
1692        community: &'static [u8],
1693        respond_as_v1: bool,
1694    ) -> Client<AdversarialTransport> {
1695        let transport = AdversarialTransport::new(pdu_type, community, respond_as_v1);
1696        let config = ClientConfig {
1697            version: Version::V2c,
1698            retry: crate::client::retry::Retry::none(),
1699            ..Default::default()
1700        };
1701        Client::new(transport, config)
1702    }
1703
1704    /// Control: the adversarial transport is otherwise well-formed, so a
1705    /// Response PDU with the sent community passes validation.
1706    #[tokio::test]
1707    async fn response_validation_accepts_well_formed_response() {
1708        let client = adversarial_client(PduType::Response, b"public", false);
1709        let result = client.get(&Oid::from_slice(&[1, 3, 6, 1, 1])).await;
1710        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1711    }
1712
1713    /// RFC 3416 Section 4.2: an echoed request-type PDU with a matching
1714    /// request-id is not a Response and must be rejected.
1715    #[tokio::test]
1716    async fn response_validation_rejects_echoed_request_pdu() {
1717        let client = adversarial_client(PduType::GetRequest, b"public", false);
1718        let err = client
1719            .get(&Oid::from_slice(&[1, 3, 6, 1, 1]))
1720            .await
1721            .unwrap_err();
1722        assert!(
1723            matches!(*err, Error::MalformedResponse { .. }),
1724            "expected MalformedResponse, got: {err}"
1725        );
1726    }
1727
1728    /// A response whose community differs from the one sent is rejected.
1729    #[tokio::test]
1730    async fn response_validation_accepts_community_mismatch() {
1731        let client = adversarial_client(PduType::Response, b"other", false);
1732        let result = client.get(&Oid::from_slice(&[1, 3, 6, 1, 1])).await;
1733        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1734    }
1735
1736    /// A v1 response to a v2c request is rejected (version mismatch).
1737    #[tokio::test]
1738    async fn response_validation_rejects_version_mismatch() {
1739        let client = adversarial_client(PduType::Response, b"public", true);
1740        let err = client
1741            .get(&Oid::from_slice(&[1, 3, 6, 1, 1]))
1742            .await
1743            .unwrap_err();
1744        assert!(
1745            matches!(*err, Error::MalformedResponse { .. }),
1746            "expected MalformedResponse, got: {err}"
1747        );
1748    }
1749}