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) => {
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}