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