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