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