Skip to main content

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}