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