async_snmp/client/builder.rs
1//! New unified client builder.
2//!
3//! This module provides the [`ClientBuilder`] type, a single entry point for
4//! constructing SNMP clients with any authentication mode (v1/v2c community
5//! or v3 USM).
6
7use std::fmt;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use std::time::Duration;
11
12use bytes::Bytes;
13
14use crate::client::retry::Retry;
15use crate::client::walk::{OidOrdering, WalkMode};
16use crate::client::{
17 Auth, ClientConfig, CommunityVersion, DEFAULT_MAX_OIDS_PER_REQUEST, DEFAULT_MAX_REPETITIONS,
18 DEFAULT_TIMEOUT, UsmConfig,
19};
20use crate::error::{Error, Result};
21use crate::transport::{TcpTransport, Transport, UdpHandle, UdpTransport};
22use crate::v3::EngineCache;
23use crate::version::Version;
24
25use super::Client;
26
27/// Target address for an SNMP client.
28///
29/// Specifies where to connect. Accepts either a combined address string
30/// or a separate host and port, which is useful when host and port are
31/// stored independently (avoids needing to format IPv6 bracket syntax).
32///
33/// # Examples
34///
35/// ```rust
36/// use async_snmp::Target;
37///
38/// // From a string (port defaults to 161 if omitted)
39/// let t: Target = "192.168.1.1:161".into();
40/// let t: Target = "switch.local".into();
41///
42/// // From a (host, port) tuple - no bracket formatting needed for IPv6
43/// let t: Target = ("fe80::1", 161).into();
44/// let t: Target = ("switch.local".to_string(), 162).into();
45///
46/// // From a SocketAddr
47/// let t: Target = "192.168.1.1:161".parse::<std::net::SocketAddr>().unwrap().into();
48/// ```
49#[derive(Debug, Clone)]
50pub enum Target {
51 /// A combined address string, e.g. `"192.168.1.1:161"` or `"[::1]:162"`.
52 /// Port defaults to 161 if not specified.
53 Address(String),
54 /// A separate host and port, e.g. `("fe80::1", 161)`.
55 HostPort(String, u16),
56}
57
58impl fmt::Display for Target {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 Target::Address(addr) => f.write_str(addr),
62 Target::HostPort(host, port) => {
63 if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) {
64 write!(f, "[{}]:{}", host, port)
65 } else {
66 write!(f, "{}:{}", host, port)
67 }
68 }
69 }
70 }
71}
72
73impl From<&str> for Target {
74 fn from(s: &str) -> Self {
75 Target::Address(s.to_string())
76 }
77}
78
79impl From<String> for Target {
80 fn from(s: String) -> Self {
81 Target::Address(s)
82 }
83}
84
85impl From<&String> for Target {
86 fn from(s: &String) -> Self {
87 Target::Address(s.clone())
88 }
89}
90
91impl From<(&str, u16)> for Target {
92 fn from((host, port): (&str, u16)) -> Self {
93 Target::HostPort(host.to_string(), port)
94 }
95}
96
97impl From<(String, u16)> for Target {
98 fn from((host, port): (String, u16)) -> Self {
99 Target::HostPort(host, port)
100 }
101}
102
103impl From<SocketAddr> for Target {
104 fn from(addr: SocketAddr) -> Self {
105 Target::HostPort(addr.ip().to_string(), addr.port())
106 }
107}
108
109/// Builder for constructing SNMP clients.
110///
111/// This is the single entry point for client construction. It supports all
112/// SNMP versions (v1, v2c, v3) through the [`Auth`] enum.
113///
114/// # Example
115///
116/// ```rust,no_run
117/// use async_snmp::{Auth, ClientBuilder, Retry};
118/// use std::time::Duration;
119///
120/// # async fn example() -> async_snmp::Result<()> {
121/// // Simple v2c client
122/// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
123/// .connect().await?;
124///
125/// // Using separate host and port (convenient for IPv6)
126/// let client = ClientBuilder::new(("fe80::1", 161), Auth::v2c("public"))
127/// .connect().await?;
128///
129/// // v3 client with authentication
130/// let client = ClientBuilder::new("192.168.1.1:161",
131/// Auth::usm("admin").auth(async_snmp::AuthProtocol::Sha256, "password"))
132/// .timeout(Duration::from_secs(10))
133/// .retry(Retry::fixed(5, Duration::ZERO))
134/// .connect().await?;
135/// # Ok(())
136/// # }
137/// ```
138pub struct ClientBuilder {
139 target: Target,
140 auth: Auth,
141 timeout: Duration,
142 retry: Retry,
143 max_oids_per_request: usize,
144 max_repetitions: u32,
145 walk_mode: WalkMode,
146 oid_ordering: OidOrdering,
147 max_walk_results: Option<usize>,
148 engine_cache: Option<Arc<EngineCache>>,
149}
150
151impl ClientBuilder {
152 /// Create a new client builder.
153 ///
154 /// # Arguments
155 ///
156 /// * `target` - The target address. Accepts a string (e.g., `"192.168.1.1"` or
157 /// `"192.168.1.1:161"`), a `(host, port)` tuple (e.g., `("fe80::1", 161)`),
158 /// or a [`SocketAddr`](std::net::SocketAddr). Port defaults to 161 if not
159 /// specified. IPv6 addresses are supported as bare (`::1`) or bracketed
160 /// (`[::1]:162`) forms.
161 /// * `auth` - Authentication configuration (community or USM)
162 ///
163 /// # Example
164 ///
165 /// ```rust,no_run
166 /// use async_snmp::{Auth, ClientBuilder};
167 ///
168 /// // Using Auth::default() for v2c with "public" community
169 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::default());
170 ///
171 /// // Using separate host and port
172 /// let builder = ClientBuilder::new(("192.168.1.1", 161), Auth::default());
173 ///
174 /// // Using Auth::v1() for SNMPv1
175 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v1("private"));
176 ///
177 /// // Using Auth::usm() for SNMPv3
178 /// let builder = ClientBuilder::new("192.168.1.1:161",
179 /// Auth::usm("admin").auth(async_snmp::AuthProtocol::Sha256, "password"));
180 /// ```
181 pub fn new(target: impl Into<Target>, auth: impl Into<Auth>) -> Self {
182 Self {
183 target: target.into(),
184 auth: auth.into(),
185 timeout: DEFAULT_TIMEOUT,
186 retry: Retry::default(),
187 max_oids_per_request: DEFAULT_MAX_OIDS_PER_REQUEST,
188 max_repetitions: DEFAULT_MAX_REPETITIONS,
189 walk_mode: WalkMode::Auto,
190 oid_ordering: OidOrdering::Strict,
191 max_walk_results: None,
192 engine_cache: None,
193 }
194 }
195
196 /// Set the request timeout (default: 5 seconds).
197 ///
198 /// This is the time to wait for a response before retrying or failing.
199 /// The total time for a request may be `timeout * (retries + 1)`.
200 ///
201 /// # Example
202 ///
203 /// ```rust
204 /// use async_snmp::{Auth, ClientBuilder};
205 /// use std::time::Duration;
206 ///
207 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
208 /// .timeout(Duration::from_secs(10));
209 /// ```
210 pub fn timeout(mut self, timeout: Duration) -> Self {
211 self.timeout = timeout;
212 self
213 }
214
215 /// Set the retry configuration (default: 3 retries, 1-second delay).
216 ///
217 /// On timeout, the client resends the request up to this many times before
218 /// returning an error. Retries are disabled for TCP (which handles
219 /// reliability at the transport layer).
220 ///
221 /// # Example
222 ///
223 /// ```rust
224 /// use async_snmp::{Auth, ClientBuilder, Retry};
225 /// use std::time::Duration;
226 ///
227 /// // No retries
228 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
229 /// .retry(Retry::none());
230 ///
231 /// // 5 retries with no delay (immediate retry on timeout)
232 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
233 /// .retry(Retry::fixed(5, Duration::ZERO));
234 ///
235 /// // Fixed delay between retries
236 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
237 /// .retry(Retry::fixed(3, Duration::from_millis(200)));
238 ///
239 /// // Exponential backoff with jitter
240 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
241 /// .retry(Retry::exponential(5)
242 /// .max_delay(Duration::from_secs(5))
243 /// .jitter(0.25));
244 /// ```
245 pub fn retry(mut self, retry: impl Into<Retry>) -> Self {
246 self.retry = retry.into();
247 self
248 }
249
250 /// Set the maximum OIDs per request (default: 10).
251 ///
252 /// Requests with more OIDs than this limit are automatically split
253 /// into multiple batches. Some devices have lower limits on the number
254 /// of OIDs they can handle in a single request. Values must be greater
255 /// than zero.
256 ///
257 /// # Example
258 ///
259 /// ```rust
260 /// use async_snmp::{Auth, ClientBuilder};
261 ///
262 /// // For devices with limited request handling capacity
263 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
264 /// .max_oids_per_request(5);
265 ///
266 /// // For high-capacity devices, increase to reduce round-trips
267 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
268 /// .max_oids_per_request(50);
269 /// ```
270 pub fn max_oids_per_request(mut self, max: usize) -> Self {
271 self.max_oids_per_request = max;
272 self
273 }
274
275 /// Set max-repetitions for GETBULK operations (default: 25).
276 ///
277 /// Controls how many values are requested per GETBULK PDU during walks.
278 /// This is a performance tuning parameter with trade-offs:
279 ///
280 /// - **Higher values**: Fewer network round-trips, faster walks on reliable
281 /// networks. But larger responses risk UDP fragmentation or may exceed
282 /// agent response buffer limits (causing truncation).
283 /// - **Lower values**: More round-trips (higher latency), but smaller
284 /// responses that fit within MTU limits.
285 ///
286 /// The default of 25 is conservative. For local/reliable networks with
287 /// capable agents, values of 50-100 can significantly speed up large walks.
288 ///
289 /// # Example
290 ///
291 /// ```rust
292 /// use async_snmp::{Auth, ClientBuilder};
293 ///
294 /// // Lower value for agents with small response buffers or lossy networks
295 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
296 /// .max_repetitions(10);
297 ///
298 /// // Higher value for fast local network walks
299 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
300 /// .max_repetitions(50);
301 /// ```
302 pub fn max_repetitions(mut self, max: u32) -> Self {
303 self.max_repetitions = max;
304 self
305 }
306
307 /// Override walk behavior for devices with buggy GETBULK (default: Auto).
308 ///
309 /// - `WalkMode::Auto`: Use GETNEXT for v1, GETBULK for v2c/v3
310 /// - `WalkMode::GetNext`: Always use GETNEXT (slower but more compatible)
311 /// - `WalkMode::GetBulk`: Always use GETBULK (faster, errors on v1)
312 ///
313 /// # Example
314 ///
315 /// ```rust
316 /// use async_snmp::{Auth, ClientBuilder, WalkMode};
317 ///
318 /// // Force GETNEXT for devices with broken GETBULK implementation
319 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
320 /// .walk_mode(WalkMode::GetNext);
321 ///
322 /// // Force GETBULK for faster walks (only v2c/v3)
323 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
324 /// .walk_mode(WalkMode::GetBulk);
325 /// ```
326 pub fn walk_mode(mut self, mode: WalkMode) -> Self {
327 self.walk_mode = mode;
328 self
329 }
330
331 /// Set OID ordering behavior for walk operations (default: Strict).
332 ///
333 /// - `OidOrdering::Strict`: Require strictly increasing OIDs. Most efficient.
334 /// - `OidOrdering::AllowNonIncreasing`: Allow non-increasing OIDs with cycle
335 /// detection. Uses O(n) memory to track seen OIDs.
336 ///
337 /// Use `AllowNonIncreasing` for buggy agents that return OIDs out of order.
338 ///
339 /// **Warning**: `AllowNonIncreasing` uses O(n) memory. Always pair with
340 /// [`max_walk_results`](Self::max_walk_results) to bound memory usage.
341 ///
342 /// # Example
343 ///
344 /// ```rust
345 /// use async_snmp::{Auth, ClientBuilder, OidOrdering};
346 ///
347 /// // Use relaxed ordering with a safety limit
348 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
349 /// .oid_ordering(OidOrdering::AllowNonIncreasing)
350 /// .max_walk_results(10_000);
351 /// ```
352 pub fn oid_ordering(mut self, ordering: OidOrdering) -> Self {
353 self.oid_ordering = ordering;
354 self
355 }
356
357 /// Set maximum results from a single walk operation (default: unlimited).
358 ///
359 /// Safety limit to prevent runaway walks. Walk terminates normally when
360 /// limit is reached.
361 ///
362 /// # Example
363 ///
364 /// ```rust
365 /// use async_snmp::{Auth, ClientBuilder};
366 ///
367 /// // Limit walks to at most 10,000 results
368 /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
369 /// .max_walk_results(10_000);
370 /// ```
371 pub fn max_walk_results(mut self, limit: usize) -> Self {
372 self.max_walk_results = Some(limit);
373 self
374 }
375
376 /// Set shared engine cache (V3 only, for polling many targets).
377 ///
378 /// Allows multiple clients to share discovered engine state, reducing
379 /// the number of discovery requests. This is particularly useful when
380 /// polling many devices with SNMPv3.
381 ///
382 /// # Example
383 ///
384 /// ```rust
385 /// use async_snmp::{Auth, AuthProtocol, ClientBuilder, EngineCache};
386 /// use std::sync::Arc;
387 ///
388 /// // Create a shared engine cache
389 /// let cache = Arc::new(EngineCache::new());
390 ///
391 /// // Multiple clients can share the same cache
392 /// let builder1 = ClientBuilder::new("192.168.1.1:161",
393 /// Auth::usm("admin").auth(AuthProtocol::Sha256, "password"))
394 /// .engine_cache(cache.clone());
395 ///
396 /// let builder2 = ClientBuilder::new("192.168.1.2:161",
397 /// Auth::usm("admin").auth(AuthProtocol::Sha256, "password"))
398 /// .engine_cache(cache.clone());
399 /// ```
400 pub fn engine_cache(mut self, cache: Arc<EngineCache>) -> Self {
401 self.engine_cache = Some(cache);
402 self
403 }
404
405 /// Validate the configuration.
406 fn validate(&self) -> Result<()> {
407 if self.max_oids_per_request == 0 {
408 return Err(
409 Error::Config("max_oids_per_request must be greater than 0".into()).boxed(),
410 );
411 }
412
413 if let Auth::Usm(usm) = &self.auth {
414 // Privacy requires authentication
415 if usm.priv_protocol.is_some() && usm.auth_protocol.is_none() {
416 return Err(Error::Config("privacy requires authentication".into()).boxed());
417 }
418 // Protocol requires password (unless using master keys)
419 if usm.auth_protocol.is_some()
420 && usm.auth_password.is_none()
421 && usm.master_keys.is_none()
422 {
423 return Err(Error::Config("auth protocol requires password".into()).boxed());
424 }
425 if usm.priv_protocol.is_some()
426 && usm.priv_password.is_none()
427 && usm.master_keys.is_none()
428 {
429 return Err(Error::Config("priv protocol requires password".into()).boxed());
430 }
431 }
432
433 // Validate walk mode for v1
434 if let Auth::Community {
435 version: CommunityVersion::V1,
436 ..
437 } = &self.auth
438 && self.walk_mode == WalkMode::GetBulk
439 {
440 return Err(Error::Config("GETBULK not supported in SNMPv1".into()).boxed());
441 }
442
443 Ok(())
444 }
445
446 /// Resolve target address to SocketAddr, defaulting to port 161.
447 ///
448 /// Accepts IPv4 (`192.168.1.1`, `192.168.1.1:162`), IPv6 (`::1`,
449 /// `[::1]:162`), hostnames (`switch.local`, `switch.local:162`), and
450 /// `(host, port)` tuples. When no port is specified, SNMP port 161 is used.
451 ///
452 /// IP addresses are parsed directly without DNS. Hostnames are resolved
453 /// asynchronously via `tokio::net::lookup_host`, bounded by the builder's
454 /// configured timeout. To bypass DNS entirely, pass a resolved IP address.
455 async fn resolve_target(&self) -> Result<SocketAddr> {
456 let (host, port) = match &self.target {
457 Target::Address(addr) => split_host_port(addr),
458 Target::HostPort(host, port) => (host.as_str(), *port),
459 };
460
461 // Try direct parse first to avoid unnecessary async DNS lookup
462 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
463 return Ok(SocketAddr::new(ip, port));
464 }
465
466 let lookup = tokio::net::lookup_host((host, port));
467 let mut addrs = tokio::time::timeout(self.timeout, lookup)
468 .await
469 .map_err(|_| {
470 Error::Config(format!("DNS lookup timed out for '{}'", self.target).into()).boxed()
471 })?
472 .map_err(|e| {
473 Error::Config(format!("could not resolve address '{}': {}", self.target, e).into())
474 .boxed()
475 })?;
476
477 addrs.next().ok_or_else(|| {
478 Error::Config(format!("could not resolve address '{}'", self.target).into()).boxed()
479 })
480 }
481
482 /// Build ClientConfig from the builder settings.
483 fn build_config(&self) -> ClientConfig {
484 match &self.auth {
485 Auth::Community { version, community } => {
486 let snmp_version = match version {
487 CommunityVersion::V1 => Version::V1,
488 CommunityVersion::V2c => Version::V2c,
489 };
490 ClientConfig {
491 version: snmp_version,
492 community: Bytes::copy_from_slice(community.as_bytes()),
493 timeout: self.timeout,
494 retry: self.retry.clone(),
495 max_oids_per_request: self.max_oids_per_request,
496 v3_security: None,
497 walk_mode: self.walk_mode,
498 oid_ordering: self.oid_ordering,
499 max_walk_results: self.max_walk_results,
500 max_repetitions: self.max_repetitions,
501 }
502 }
503 Auth::Usm(usm) => {
504 let mut security = UsmConfig::new(Bytes::copy_from_slice(usm.username.as_bytes()));
505 if let Some(context_name) = &usm.context_name {
506 security =
507 security.context_name(Bytes::copy_from_slice(context_name.as_bytes()));
508 }
509
510 // Prefer master_keys over passwords if available
511 if let Some(ref master_keys) = usm.master_keys {
512 security = security.with_master_keys(master_keys.clone());
513 } else {
514 if let (Some(auth_proto), Some(auth_pass)) =
515 (usm.auth_protocol, &usm.auth_password)
516 {
517 security = security.auth(auth_proto, auth_pass.as_bytes());
518 }
519
520 if let (Some(priv_proto), Some(priv_pass)) =
521 (usm.priv_protocol, &usm.priv_password)
522 {
523 security = security.privacy(priv_proto, priv_pass.as_bytes());
524 }
525 }
526
527 ClientConfig {
528 version: Version::V3,
529 community: Bytes::new(),
530 timeout: self.timeout,
531 retry: self.retry.clone(),
532 max_oids_per_request: self.max_oids_per_request,
533 v3_security: Some(security),
534 walk_mode: self.walk_mode,
535 oid_ordering: self.oid_ordering,
536 max_walk_results: self.max_walk_results,
537 max_repetitions: self.max_repetitions,
538 }
539 }
540 }
541 }
542
543 /// Build the client with the given transport.
544 fn build_inner<T: Transport>(self, transport: T) -> Client<T> {
545 let config = self.build_config();
546
547 if let Some(cache) = self.engine_cache {
548 Client::with_engine_cache(transport, config, cache)
549 } else {
550 Client::new(transport, config)
551 }
552 }
553
554 /// Connect via UDP (default).
555 ///
556 /// Creates a new UDP socket and connects to the target address. This is the
557 /// recommended connection method for most use cases due to UDP's lower
558 /// overhead compared to TCP.
559 ///
560 /// For polling many targets, consider using a shared
561 /// [`UdpTransport`](crate::transport::UdpTransport) with [`build_with()`](Self::build_with).
562 ///
563 /// # Errors
564 ///
565 /// Returns an error if the configuration is invalid or the connection fails.
566 ///
567 /// # Example
568 ///
569 /// ```rust,no_run
570 /// use async_snmp::{Auth, ClientBuilder};
571 ///
572 /// # async fn example() -> async_snmp::Result<()> {
573 /// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
574 /// .connect()
575 /// .await?;
576 /// # Ok(())
577 /// # }
578 /// ```
579 pub async fn connect(self) -> Result<Client<UdpHandle>> {
580 self.validate()?;
581 let addr = self.resolve_target().await?;
582 // Match bind address to target address family for cross-platform
583 // compatibility. Dual-stack ([::]:0) only works reliably on Linux;
584 // macOS/BSD default to IPV6_V6ONLY=1 and reject IPv4 targets.
585 let bind_addr = if addr.is_ipv6() {
586 "[::]:0"
587 } else {
588 "0.0.0.0:0"
589 };
590 let transport = UdpTransport::bind(bind_addr).await?;
591 let handle = transport.handle(addr);
592 Ok(self.build_inner(handle))
593 }
594
595 /// Build a client using a shared UDP transport.
596 ///
597 /// Creates a handle for the builder's target address from the given transport.
598 /// This is the recommended way to create multiple clients that share a socket.
599 ///
600 /// # Example
601 ///
602 /// ```rust,no_run
603 /// use async_snmp::{Auth, ClientBuilder};
604 /// use async_snmp::transport::UdpTransport;
605 ///
606 /// # async fn example() -> async_snmp::Result<()> {
607 /// let transport = UdpTransport::bind("0.0.0.0:0").await?;
608 ///
609 /// let client1 = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
610 /// .build_with(&transport).await?;
611 /// let client2 = ClientBuilder::new("192.168.1.2:161", Auth::v2c("public"))
612 /// .build_with(&transport).await?;
613 /// # Ok(())
614 /// # }
615 /// ```
616 pub async fn build_with(self, transport: &UdpTransport) -> Result<Client<UdpHandle>> {
617 self.validate()?;
618 let addr = self.resolve_target().await?;
619 let handle = transport.handle(addr);
620 Ok(self.build_inner(handle))
621 }
622
623 /// Connect via TCP.
624 ///
625 /// Establishes a TCP connection to the target. Use this when:
626 /// - UDP is blocked by firewalls
627 /// - Messages exceed UDP's maximum datagram size
628 /// - Reliable delivery is required
629 ///
630 /// Note that TCP has higher overhead than UDP due to connection setup
631 /// and per-message framing.
632 ///
633 /// For advanced TCP configuration (connection timeout, keepalive, buffer
634 /// sizes), construct a [`TcpTransport`] directly and use [`Client::new()`].
635 ///
636 /// # Errors
637 ///
638 /// Returns an error if the configuration is invalid or the connection fails.
639 ///
640 /// # Example
641 ///
642 /// ```rust,no_run
643 /// use async_snmp::{Auth, ClientBuilder};
644 ///
645 /// # async fn example() -> async_snmp::Result<()> {
646 /// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
647 /// .connect_tcp()
648 /// .await?;
649 /// # Ok(())
650 /// # }
651 /// ```
652 pub async fn connect_tcp(self) -> Result<Client<TcpTransport>> {
653 self.validate()?;
654 let addr = self.resolve_target().await?;
655 let transport = TcpTransport::connect(addr).await?;
656 Ok(self.build_inner(transport))
657 }
658}
659
660/// Default SNMP port.
661const DEFAULT_PORT: u16 = 161;
662
663/// Split a target string into (host, port), defaulting to port 161.
664///
665/// Handles IPv4 (`192.168.1.1`), IPv4 with port (`192.168.1.1:162`),
666/// bare IPv6 (`fe80::1`), bracketed IPv6 (`[::1]`, `[::1]:162`),
667/// and hostnames (`switch.local`, `switch.local:162`).
668fn split_host_port(target: &str) -> (&str, u16) {
669 // Bracketed IPv6: [addr]:port or [addr]
670 if let Some(rest) = target.strip_prefix('[') {
671 if let Some((addr, port)) = rest.rsplit_once("]:")
672 && let Ok(p) = port.parse()
673 {
674 return (addr, p);
675 }
676 return (rest.trim_end_matches(']'), DEFAULT_PORT);
677 }
678
679 // IPv4 or hostname: last colon is the port separator, but only if the
680 // host part doesn't also contain colons (which would make it bare IPv6)
681 if let Some((host, port)) = target.rsplit_once(':')
682 && !host.contains(':')
683 && let Ok(p) = port.parse::<u16>()
684 {
685 return (host, p);
686 }
687
688 // No port found (bare IPv4, IPv6, or hostname)
689 (target, DEFAULT_PORT)
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use crate::v3::{AuthProtocol, MasterKeys, PrivProtocol};
696
697 #[test]
698 fn test_builder_defaults() {
699 let builder = ClientBuilder::new("192.168.1.1:161", Auth::default());
700 assert!(matches!(builder.target, Target::Address(ref s) if s == "192.168.1.1:161"));
701 assert_eq!(builder.timeout, DEFAULT_TIMEOUT);
702 assert_eq!(builder.retry.max_attempts, 3);
703 assert_eq!(builder.max_oids_per_request, DEFAULT_MAX_OIDS_PER_REQUEST);
704 assert_eq!(builder.max_repetitions, DEFAULT_MAX_REPETITIONS);
705 assert_eq!(builder.walk_mode, WalkMode::Auto);
706 assert_eq!(builder.oid_ordering, OidOrdering::Strict);
707 assert!(builder.max_walk_results.is_none());
708 assert!(builder.engine_cache.is_none());
709 }
710
711 #[test]
712 fn test_builder_with_options() {
713 let cache = Arc::new(EngineCache::new());
714 let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("private"))
715 .timeout(Duration::from_secs(10))
716 .retry(Retry::fixed(5, Duration::ZERO))
717 .max_oids_per_request(20)
718 .max_repetitions(50)
719 .walk_mode(WalkMode::GetNext)
720 .oid_ordering(OidOrdering::AllowNonIncreasing)
721 .max_walk_results(1000)
722 .engine_cache(cache.clone());
723
724 assert_eq!(builder.timeout, Duration::from_secs(10));
725 assert_eq!(builder.retry.max_attempts, 5);
726 assert_eq!(builder.max_oids_per_request, 20);
727 assert_eq!(builder.max_repetitions, 50);
728 assert_eq!(builder.walk_mode, WalkMode::GetNext);
729 assert_eq!(builder.oid_ordering, OidOrdering::AllowNonIncreasing);
730 assert_eq!(builder.max_walk_results, Some(1000));
731 assert!(builder.engine_cache.is_some());
732 }
733
734 #[test]
735 fn test_validate_community_ok() {
736 let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"));
737 assert!(builder.validate().is_ok());
738 }
739
740 #[test]
741 fn test_validate_zero_max_oids_per_request_error() {
742 let builder =
743 ClientBuilder::new("192.168.1.1:161", Auth::v2c("public")).max_oids_per_request(0);
744 let err = builder.validate().unwrap_err();
745 assert!(matches!(
746 *err,
747 Error::Config(ref msg) if msg.contains("max_oids_per_request must be greater than 0")
748 ));
749 }
750
751 #[test]
752 fn test_validate_usm_no_auth_no_priv_ok() {
753 let builder = ClientBuilder::new("192.168.1.1:161", Auth::usm("readonly"));
754 assert!(builder.validate().is_ok());
755 }
756
757 #[test]
758 fn test_validate_usm_auth_no_priv_ok() {
759 let builder = ClientBuilder::new(
760 "192.168.1.1:161",
761 Auth::usm("admin").auth(AuthProtocol::Sha256, "authpass"),
762 );
763 assert!(builder.validate().is_ok());
764 }
765
766 #[test]
767 fn test_validate_usm_auth_priv_ok() {
768 let builder = ClientBuilder::new(
769 "192.168.1.1:161",
770 Auth::usm("admin")
771 .auth(AuthProtocol::Sha256, "authpass")
772 .privacy(PrivProtocol::Aes128, "privpass"),
773 );
774 assert!(builder.validate().is_ok());
775 }
776
777 #[test]
778 fn test_validate_priv_without_auth_error() {
779 // Manually construct UsmAuth with priv but no auth
780 let usm = crate::client::UsmAuth {
781 username: "user".to_string(),
782 auth_protocol: None,
783 auth_password: None,
784 priv_protocol: Some(PrivProtocol::Aes128),
785 priv_password: Some("privpass".to_string()),
786 context_name: None,
787 master_keys: None,
788 };
789 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
790 let err = builder.validate().unwrap_err();
791 assert!(
792 matches!(*err, Error::Config(ref msg) if msg.contains("privacy requires authentication"))
793 );
794 }
795
796 #[test]
797 fn test_validate_auth_protocol_without_password_error() {
798 // Manually construct UsmAuth with auth protocol but no password
799 let usm = crate::client::UsmAuth {
800 username: "user".to_string(),
801 auth_protocol: Some(AuthProtocol::Sha256),
802 auth_password: None,
803 priv_protocol: None,
804 priv_password: None,
805 context_name: None,
806 master_keys: None,
807 };
808 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
809 let err = builder.validate().unwrap_err();
810 assert!(
811 matches!(*err, Error::Config(ref msg) if msg.contains("auth protocol requires password"))
812 );
813 }
814
815 #[test]
816 fn test_validate_priv_protocol_without_password_error() {
817 // Manually construct UsmAuth with priv protocol but no password
818 let usm = crate::client::UsmAuth {
819 username: "user".to_string(),
820 auth_protocol: Some(AuthProtocol::Sha256),
821 auth_password: Some("authpass".to_string()),
822 priv_protocol: Some(PrivProtocol::Aes128),
823 priv_password: None,
824 context_name: None,
825 master_keys: None,
826 };
827 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
828 let err = builder.validate().unwrap_err();
829 assert!(
830 matches!(*err, Error::Config(ref msg) if msg.contains("priv protocol requires password"))
831 );
832 }
833
834 #[test]
835 fn test_builder_with_usm_builder() {
836 // Test that UsmBuilder can be passed directly (via Into<Auth>)
837 let builder = ClientBuilder::new(
838 "192.168.1.1:161",
839 Auth::usm("admin").auth(AuthProtocol::Sha256, "pass"),
840 );
841 assert!(builder.validate().is_ok());
842 }
843
844 #[test]
845 fn test_validate_master_keys_bypass_auth_password() {
846 // When master keys are set, auth password is not required
847 let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpass");
848 let usm = crate::client::UsmAuth {
849 username: "user".to_string(),
850 auth_protocol: Some(AuthProtocol::Sha256),
851 auth_password: None, // No password
852 priv_protocol: None,
853 priv_password: None,
854 context_name: None,
855 master_keys: Some(master_keys),
856 };
857 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
858 assert!(builder.validate().is_ok());
859 }
860
861 #[test]
862 fn test_validate_master_keys_bypass_priv_password() {
863 // When master keys are set, priv password is not required
864 let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpass")
865 .with_privacy(PrivProtocol::Aes128, b"privpass");
866 let usm = crate::client::UsmAuth {
867 username: "user".to_string(),
868 auth_protocol: Some(AuthProtocol::Sha256),
869 auth_password: None, // No password
870 priv_protocol: Some(PrivProtocol::Aes128),
871 priv_password: None, // No password
872 context_name: None,
873 master_keys: Some(master_keys),
874 };
875 let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
876 assert!(builder.validate().is_ok());
877 }
878
879 #[test]
880 fn test_build_config_preserves_v3_context_name() {
881 let builder = ClientBuilder::new(
882 "192.168.1.1:161",
883 Auth::usm("admin")
884 .auth(AuthProtocol::Sha256, "authpass")
885 .context_name("vlan100"),
886 );
887
888 let config = builder.build_config();
889 let security = config
890 .v3_security
891 .expect("expected v3 security config to be built");
892
893 assert_eq!(security.context_name.as_ref(), b"vlan100");
894 }
895
896 #[test]
897 fn test_builder_with_host_port_tuple() {
898 let builder = ClientBuilder::new(("fe80::1", 161), Auth::default());
899 assert!(matches!(
900 builder.target,
901 Target::HostPort(ref h, 161) if h == "fe80::1"
902 ));
903 }
904
905 #[test]
906 fn test_builder_with_string_host_port_tuple() {
907 let builder = ClientBuilder::new(("switch.local".to_string(), 162), Auth::v2c("public"));
908 assert!(matches!(
909 builder.target,
910 Target::HostPort(ref h, 162) if h == "switch.local"
911 ));
912 }
913
914 #[test]
915 fn test_target_from_str() {
916 let t: Target = "192.168.1.1:161".into();
917 assert!(matches!(t, Target::Address(ref s) if s == "192.168.1.1:161"));
918 }
919
920 #[test]
921 fn test_target_from_tuple() {
922 let t: Target = ("fe80::1", 161).into();
923 assert!(matches!(t, Target::HostPort(ref h, 161) if h == "fe80::1"));
924 }
925
926 #[test]
927 fn test_target_from_socket_addr() {
928 let addr: SocketAddr = "192.168.1.1:162".parse().unwrap();
929 let t: Target = addr.into();
930 assert!(matches!(t, Target::HostPort(ref h, 162) if h == "192.168.1.1"));
931 }
932
933 #[test]
934 fn test_target_display() {
935 let t: Target = "192.168.1.1:161".into();
936 assert_eq!(t.to_string(), "192.168.1.1:161");
937
938 let t: Target = ("fe80::1", 161).into();
939 assert_eq!(t.to_string(), "[fe80::1]:161");
940
941 let addr: SocketAddr = "[::1]:162".parse().unwrap();
942 let t: Target = addr.into();
943 assert_eq!(t.to_string(), "[::1]:162");
944 }
945
946 #[tokio::test]
947 async fn test_resolve_target_socket_addr() {
948 let addr: SocketAddr = "10.0.0.1:162".parse().unwrap();
949 let builder = ClientBuilder::new(addr, Auth::default());
950 let resolved = builder.resolve_target().await.unwrap();
951 assert_eq!(resolved, addr);
952 }
953
954 #[tokio::test]
955 async fn test_resolve_target_host_port_ipv4() {
956 let builder = ClientBuilder::new(("192.168.1.1", 162), Auth::default());
957 let addr = builder.resolve_target().await.unwrap();
958 assert_eq!(addr, "192.168.1.1:162".parse().unwrap());
959 }
960
961 #[tokio::test]
962 async fn test_resolve_target_host_port_ipv6() {
963 let builder = ClientBuilder::new(("::1", 161), Auth::default());
964 let addr = builder.resolve_target().await.unwrap();
965 assert_eq!(addr, "[::1]:161".parse().unwrap());
966 }
967
968 #[tokio::test]
969 async fn test_resolve_target_string_still_works() {
970 let builder = ClientBuilder::new("10.0.0.1:162", Auth::default());
971 let addr = builder.resolve_target().await.unwrap();
972 assert_eq!(addr, "10.0.0.1:162".parse().unwrap());
973 }
974
975 #[test]
976 fn test_split_host_port_ipv4_with_port() {
977 assert_eq!(split_host_port("192.168.1.1:162"), ("192.168.1.1", 162));
978 }
979
980 #[test]
981 fn test_split_host_port_ipv4_default() {
982 assert_eq!(split_host_port("192.168.1.1"), ("192.168.1.1", 161));
983 }
984
985 #[test]
986 fn test_split_host_port_ipv6_bare() {
987 assert_eq!(split_host_port("fe80::1"), ("fe80::1", 161));
988 }
989
990 #[test]
991 fn test_split_host_port_ipv6_loopback() {
992 assert_eq!(split_host_port("::1"), ("::1", 161));
993 }
994
995 #[test]
996 fn test_split_host_port_ipv6_bracketed_with_port() {
997 assert_eq!(split_host_port("[fe80::1]:162"), ("fe80::1", 162));
998 }
999
1000 #[test]
1001 fn test_split_host_port_ipv6_bracketed_default() {
1002 assert_eq!(split_host_port("[::1]"), ("::1", 161));
1003 }
1004
1005 #[test]
1006 fn test_split_host_port_hostname() {
1007 assert_eq!(split_host_port("switch.local"), ("switch.local", 161));
1008 }
1009
1010 #[test]
1011 fn test_split_host_port_hostname_with_port() {
1012 assert_eq!(split_host_port("switch.local:162"), ("switch.local", 162));
1013 }
1014}