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