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::net::SocketAddr;
8use std::sync::Arc;
9use std::time::Duration;
10
11use bytes::Bytes;
12
13use crate::client::retry::Retry;
14use crate::client::walk::{OidOrdering, WalkMode};
15use crate::client::{
16    Auth, ClientConfig, CommunityVersion, DEFAULT_MAX_OIDS_PER_REQUEST, DEFAULT_MAX_REPETITIONS,
17    DEFAULT_TIMEOUT, UsmConfig,
18};
19use crate::error::{Error, Result};
20use crate::transport::{TcpTransport, Transport, UdpHandle, UdpTransport};
21use crate::v3::EngineCache;
22use crate::version::Version;
23
24use super::Client;
25
26/// Builder for constructing SNMP clients.
27///
28/// This is the single entry point for client construction. It supports all
29/// SNMP versions (v1, v2c, v3) through the [`Auth`] enum.
30///
31/// # Example
32///
33/// ```rust,no_run
34/// use async_snmp::{Auth, ClientBuilder, Retry};
35/// use std::time::Duration;
36///
37/// # async fn example() -> async_snmp::Result<()> {
38/// // Simple v2c client
39/// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
40///     .connect().await?;
41///
42/// // v3 client with authentication
43/// let client = ClientBuilder::new("192.168.1.1:161",
44///     Auth::usm("admin").auth(async_snmp::AuthProtocol::Sha256, "password"))
45///     .timeout(Duration::from_secs(10))
46///     .retry(Retry::fixed(5, Duration::ZERO))
47///     .connect().await?;
48/// # Ok(())
49/// # }
50/// ```
51pub struct ClientBuilder {
52    target: String,
53    auth: Auth,
54    timeout: Duration,
55    retry: Retry,
56    max_oids_per_request: usize,
57    max_repetitions: u32,
58    walk_mode: WalkMode,
59    oid_ordering: OidOrdering,
60    max_walk_results: Option<usize>,
61    engine_cache: Option<Arc<EngineCache>>,
62}
63
64impl ClientBuilder {
65    /// Create a new client builder.
66    ///
67    /// # Arguments
68    ///
69    /// * `target` - The target address (e.g., "192.168.1.1" or "192.168.1.1:161").
70    ///   Port defaults to 161 if not specified. IPv6 addresses are supported
71    ///   as bare (`::1`) or bracketed (`[::1]:162`) forms.
72    /// * `auth` - Authentication configuration (community or USM)
73    ///
74    /// # Example
75    ///
76    /// ```rust,no_run
77    /// use async_snmp::{Auth, ClientBuilder};
78    ///
79    /// // Using Auth::default() for v2c with "public" community
80    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::default());
81    ///
82    /// // Using Auth::v1() for SNMPv1
83    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v1("private"));
84    ///
85    /// // Using Auth::usm() for SNMPv3
86    /// let builder = ClientBuilder::new("192.168.1.1:161",
87    ///     Auth::usm("admin").auth(async_snmp::AuthProtocol::Sha256, "password"));
88    /// ```
89    pub fn new(target: impl Into<String>, auth: impl Into<Auth>) -> Self {
90        Self {
91            target: target.into(),
92            auth: auth.into(),
93            timeout: DEFAULT_TIMEOUT,
94            retry: Retry::default(),
95            max_oids_per_request: DEFAULT_MAX_OIDS_PER_REQUEST,
96            max_repetitions: DEFAULT_MAX_REPETITIONS,
97            walk_mode: WalkMode::Auto,
98            oid_ordering: OidOrdering::Strict,
99            max_walk_results: None,
100            engine_cache: None,
101        }
102    }
103
104    /// Set the request timeout (default: 5 seconds).
105    ///
106    /// This is the time to wait for a response before retrying or failing.
107    /// The total time for a request may be `timeout * (retries + 1)`.
108    ///
109    /// # Example
110    ///
111    /// ```rust
112    /// use async_snmp::{Auth, ClientBuilder};
113    /// use std::time::Duration;
114    ///
115    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
116    ///     .timeout(Duration::from_secs(10));
117    /// ```
118    pub fn timeout(mut self, timeout: Duration) -> Self {
119        self.timeout = timeout;
120        self
121    }
122
123    /// Set the retry configuration (default: 3 retries, no backoff).
124    ///
125    /// On timeout, the client resends the request up to this many times before
126    /// returning an error. Retries are disabled for TCP (which handles
127    /// reliability at the transport layer).
128    ///
129    /// # Example
130    ///
131    /// ```rust
132    /// use async_snmp::{Auth, ClientBuilder, Retry};
133    /// use std::time::Duration;
134    ///
135    /// // No retries
136    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
137    ///     .retry(Retry::none());
138    ///
139    /// // 5 retries with no delay (immediate retry on timeout)
140    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
141    ///     .retry(Retry::fixed(5, Duration::ZERO));
142    ///
143    /// // Fixed delay between retries
144    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
145    ///     .retry(Retry::fixed(3, Duration::from_millis(200)));
146    ///
147    /// // Exponential backoff with jitter
148    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
149    ///     .retry(Retry::exponential(5)
150    ///         .max_delay(Duration::from_secs(5))
151    ///         .jitter(0.25));
152    /// ```
153    pub fn retry(mut self, retry: impl Into<Retry>) -> Self {
154        self.retry = retry.into();
155        self
156    }
157
158    /// Set the maximum OIDs per request (default: 10).
159    ///
160    /// Requests with more OIDs than this limit are automatically split
161    /// into multiple batches. Some devices have lower limits on the number
162    /// of OIDs they can handle in a single request.
163    ///
164    /// # Example
165    ///
166    /// ```rust
167    /// use async_snmp::{Auth, ClientBuilder};
168    ///
169    /// // For devices with limited request handling capacity
170    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
171    ///     .max_oids_per_request(5);
172    ///
173    /// // For high-capacity devices, increase to reduce round-trips
174    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
175    ///     .max_oids_per_request(50);
176    /// ```
177    pub fn max_oids_per_request(mut self, max: usize) -> Self {
178        self.max_oids_per_request = max;
179        self
180    }
181
182    /// Set max-repetitions for GETBULK operations (default: 25).
183    ///
184    /// Controls how many values are requested per GETBULK PDU during walks.
185    /// This is a performance tuning parameter with trade-offs:
186    ///
187    /// - **Higher values**: Fewer network round-trips, faster walks on reliable
188    ///   networks. But larger responses risk UDP fragmentation or may exceed
189    ///   agent response buffer limits (causing truncation).
190    /// - **Lower values**: More round-trips (higher latency), but smaller
191    ///   responses that fit within MTU limits.
192    ///
193    /// The default of 25 is conservative. For local/reliable networks with
194    /// capable agents, values of 50-100 can significantly speed up large walks.
195    ///
196    /// # Example
197    ///
198    /// ```rust
199    /// use async_snmp::{Auth, ClientBuilder};
200    ///
201    /// // Lower value for agents with small response buffers or lossy networks
202    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
203    ///     .max_repetitions(10);
204    ///
205    /// // Higher value for fast local network walks
206    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
207    ///     .max_repetitions(50);
208    /// ```
209    pub fn max_repetitions(mut self, max: u32) -> Self {
210        self.max_repetitions = max;
211        self
212    }
213
214    /// Override walk behavior for devices with buggy GETBULK (default: Auto).
215    ///
216    /// - `WalkMode::Auto`: Use GETNEXT for v1, GETBULK for v2c/v3
217    /// - `WalkMode::GetNext`: Always use GETNEXT (slower but more compatible)
218    /// - `WalkMode::GetBulk`: Always use GETBULK (faster, errors on v1)
219    ///
220    /// # Example
221    ///
222    /// ```rust
223    /// use async_snmp::{Auth, ClientBuilder, WalkMode};
224    ///
225    /// // Force GETNEXT for devices with broken GETBULK implementation
226    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
227    ///     .walk_mode(WalkMode::GetNext);
228    ///
229    /// // Force GETBULK for faster walks (only v2c/v3)
230    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
231    ///     .walk_mode(WalkMode::GetBulk);
232    /// ```
233    pub fn walk_mode(mut self, mode: WalkMode) -> Self {
234        self.walk_mode = mode;
235        self
236    }
237
238    /// Set OID ordering behavior for walk operations (default: Strict).
239    ///
240    /// - `OidOrdering::Strict`: Require strictly increasing OIDs. Most efficient.
241    /// - `OidOrdering::AllowNonIncreasing`: Allow non-increasing OIDs with cycle
242    ///   detection. Uses O(n) memory to track seen OIDs.
243    ///
244    /// Use `AllowNonIncreasing` for buggy agents that return OIDs out of order.
245    ///
246    /// **Warning**: `AllowNonIncreasing` uses O(n) memory. Always pair with
247    /// [`max_walk_results`](Self::max_walk_results) to bound memory usage.
248    ///
249    /// # Example
250    ///
251    /// ```rust
252    /// use async_snmp::{Auth, ClientBuilder, OidOrdering};
253    ///
254    /// // Use relaxed ordering with a safety limit
255    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
256    ///     .oid_ordering(OidOrdering::AllowNonIncreasing)
257    ///     .max_walk_results(10_000);
258    /// ```
259    pub fn oid_ordering(mut self, ordering: OidOrdering) -> Self {
260        self.oid_ordering = ordering;
261        self
262    }
263
264    /// Set maximum results from a single walk operation (default: unlimited).
265    ///
266    /// Safety limit to prevent runaway walks. Walk terminates normally when
267    /// limit is reached.
268    ///
269    /// # Example
270    ///
271    /// ```rust
272    /// use async_snmp::{Auth, ClientBuilder};
273    ///
274    /// // Limit walks to at most 10,000 results
275    /// let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
276    ///     .max_walk_results(10_000);
277    /// ```
278    pub fn max_walk_results(mut self, limit: usize) -> Self {
279        self.max_walk_results = Some(limit);
280        self
281    }
282
283    /// Set shared engine cache (V3 only, for polling many targets).
284    ///
285    /// Allows multiple clients to share discovered engine state, reducing
286    /// the number of discovery requests. This is particularly useful when
287    /// polling many devices with SNMPv3.
288    ///
289    /// # Example
290    ///
291    /// ```rust
292    /// use async_snmp::{Auth, AuthProtocol, ClientBuilder, EngineCache};
293    /// use std::sync::Arc;
294    ///
295    /// // Create a shared engine cache
296    /// let cache = Arc::new(EngineCache::new());
297    ///
298    /// // Multiple clients can share the same cache
299    /// let builder1 = ClientBuilder::new("192.168.1.1:161",
300    ///     Auth::usm("admin").auth(AuthProtocol::Sha256, "password"))
301    ///     .engine_cache(cache.clone());
302    ///
303    /// let builder2 = ClientBuilder::new("192.168.1.2:161",
304    ///     Auth::usm("admin").auth(AuthProtocol::Sha256, "password"))
305    ///     .engine_cache(cache.clone());
306    /// ```
307    pub fn engine_cache(mut self, cache: Arc<EngineCache>) -> Self {
308        self.engine_cache = Some(cache);
309        self
310    }
311
312    /// Validate the configuration.
313    fn validate(&self) -> Result<()> {
314        if let Auth::Usm(usm) = &self.auth {
315            // Privacy requires authentication
316            if usm.priv_protocol.is_some() && usm.auth_protocol.is_none() {
317                return Err(Error::Config("privacy requires authentication".into()).boxed());
318            }
319            // Protocol requires password (unless using master keys)
320            if usm.auth_protocol.is_some()
321                && usm.auth_password.is_none()
322                && usm.master_keys.is_none()
323            {
324                return Err(Error::Config("auth protocol requires password".into()).boxed());
325            }
326            if usm.priv_protocol.is_some()
327                && usm.priv_password.is_none()
328                && usm.master_keys.is_none()
329            {
330                return Err(Error::Config("priv protocol requires password".into()).boxed());
331            }
332        }
333
334        // Validate walk mode for v1
335        if let Auth::Community {
336            version: CommunityVersion::V1,
337            ..
338        } = &self.auth
339            && self.walk_mode == WalkMode::GetBulk
340        {
341            return Err(Error::Config("GETBULK not supported in SNMPv1".into()).boxed());
342        }
343
344        Ok(())
345    }
346
347    /// Resolve target address to SocketAddr, defaulting to port 161.
348    ///
349    /// Accepts IPv4 (`192.168.1.1`, `192.168.1.1:162`), IPv6 (`::1`,
350    /// `[::1]:162`), and hostnames (`switch.local`, `switch.local:162`).
351    /// When no port is specified, SNMP port 161 is used.
352    ///
353    /// IP addresses are parsed directly without DNS. Hostnames are resolved
354    /// asynchronously via `tokio::net::lookup_host`, bounded by the builder's
355    /// configured timeout. To bypass DNS entirely, pass a resolved IP:port.
356    async fn resolve_target(&self) -> Result<SocketAddr> {
357        let (host, port) = split_host_port(&self.target);
358
359        // Try direct parse first to avoid unnecessary async DNS lookup
360        if let Ok(ip) = host.parse::<std::net::IpAddr>() {
361            return Ok(SocketAddr::new(ip, port));
362        }
363
364        let lookup = tokio::net::lookup_host((host, port));
365        let mut addrs = tokio::time::timeout(self.timeout, lookup)
366            .await
367            .map_err(|_| {
368                Error::Config(format!("DNS lookup timed out for '{}'", self.target).into()).boxed()
369            })?
370            .map_err(|e| {
371                Error::Config(format!("could not resolve address '{}': {}", self.target, e).into())
372                    .boxed()
373            })?;
374
375        addrs.next().ok_or_else(|| {
376            Error::Config(format!("could not resolve address '{}'", self.target).into()).boxed()
377        })
378    }
379
380    /// Build ClientConfig from the builder settings.
381    fn build_config(&self) -> ClientConfig {
382        match &self.auth {
383            Auth::Community { version, community } => {
384                let snmp_version = match version {
385                    CommunityVersion::V1 => Version::V1,
386                    CommunityVersion::V2c => Version::V2c,
387                };
388                ClientConfig {
389                    version: snmp_version,
390                    community: Bytes::copy_from_slice(community.as_bytes()),
391                    timeout: self.timeout,
392                    retry: self.retry.clone(),
393                    max_oids_per_request: self.max_oids_per_request,
394                    v3_security: None,
395                    walk_mode: self.walk_mode,
396                    oid_ordering: self.oid_ordering,
397                    max_walk_results: self.max_walk_results,
398                    max_repetitions: self.max_repetitions,
399                }
400            }
401            Auth::Usm(usm) => {
402                let mut security = UsmConfig::new(Bytes::copy_from_slice(usm.username.as_bytes()));
403
404                // Prefer master_keys over passwords if available
405                if let Some(ref master_keys) = usm.master_keys {
406                    security = security.with_master_keys(master_keys.clone());
407                } else {
408                    if let (Some(auth_proto), Some(auth_pass)) =
409                        (usm.auth_protocol, &usm.auth_password)
410                    {
411                        security = security.auth(auth_proto, auth_pass.as_bytes());
412                    }
413
414                    if let (Some(priv_proto), Some(priv_pass)) =
415                        (usm.priv_protocol, &usm.priv_password)
416                    {
417                        security = security.privacy(priv_proto, priv_pass.as_bytes());
418                    }
419                }
420
421                ClientConfig {
422                    version: Version::V3,
423                    community: Bytes::new(),
424                    timeout: self.timeout,
425                    retry: self.retry.clone(),
426                    max_oids_per_request: self.max_oids_per_request,
427                    v3_security: Some(security),
428                    walk_mode: self.walk_mode,
429                    oid_ordering: self.oid_ordering,
430                    max_walk_results: self.max_walk_results,
431                    max_repetitions: self.max_repetitions,
432                }
433            }
434        }
435    }
436
437    /// Build the client with the given transport.
438    fn build_inner<T: Transport>(self, transport: T) -> Client<T> {
439        let config = self.build_config();
440
441        if let Some(cache) = self.engine_cache {
442            Client::with_engine_cache(transport, config, cache)
443        } else {
444            Client::new(transport, config)
445        }
446    }
447
448    /// Connect via UDP (default).
449    ///
450    /// Creates a new UDP socket and connects to the target address. This is the
451    /// recommended connection method for most use cases due to UDP's lower
452    /// overhead compared to TCP.
453    ///
454    /// For polling many targets, consider using a shared
455    /// [`UdpTransport`](crate::transport::UdpTransport) with [`build_with()`](Self::build_with).
456    ///
457    /// # Errors
458    ///
459    /// Returns an error if the configuration is invalid or the connection fails.
460    ///
461    /// # Example
462    ///
463    /// ```rust,no_run
464    /// use async_snmp::{Auth, ClientBuilder};
465    ///
466    /// # async fn example() -> async_snmp::Result<()> {
467    /// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
468    ///     .connect()
469    ///     .await?;
470    /// # Ok(())
471    /// # }
472    /// ```
473    pub async fn connect(self) -> Result<Client<UdpHandle>> {
474        self.validate()?;
475        let addr = self.resolve_target().await?;
476        // Match bind address to target address family for cross-platform
477        // compatibility. Dual-stack ([::]:0) only works reliably on Linux;
478        // macOS/BSD default to IPV6_V6ONLY=1 and reject IPv4 targets.
479        let bind_addr = if addr.is_ipv6() {
480            "[::]:0"
481        } else {
482            "0.0.0.0:0"
483        };
484        let transport = UdpTransport::bind(bind_addr).await?;
485        let handle = transport.handle(addr);
486        Ok(self.build_inner(handle))
487    }
488
489    /// Build a client using a shared UDP transport.
490    ///
491    /// Creates a handle for the builder's target address from the given transport.
492    /// This is the recommended way to create multiple clients that share a socket.
493    ///
494    /// # Example
495    ///
496    /// ```rust,no_run
497    /// use async_snmp::{Auth, ClientBuilder};
498    /// use async_snmp::transport::UdpTransport;
499    ///
500    /// # async fn example() -> async_snmp::Result<()> {
501    /// let transport = UdpTransport::bind("0.0.0.0:0").await?;
502    ///
503    /// let client1 = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
504    ///     .build_with(&transport).await?;
505    /// let client2 = ClientBuilder::new("192.168.1.2:161", Auth::v2c("public"))
506    ///     .build_with(&transport).await?;
507    /// # Ok(())
508    /// # }
509    /// ```
510    pub async fn build_with(self, transport: &UdpTransport) -> Result<Client<UdpHandle>> {
511        self.validate()?;
512        let addr = self.resolve_target().await?;
513        let handle = transport.handle(addr);
514        Ok(self.build_inner(handle))
515    }
516
517    /// Connect via TCP.
518    ///
519    /// Establishes a TCP connection to the target. Use this when:
520    /// - UDP is blocked by firewalls
521    /// - Messages exceed UDP's maximum datagram size
522    /// - Reliable delivery is required
523    ///
524    /// Note that TCP has higher overhead than UDP due to connection setup
525    /// and per-message framing.
526    ///
527    /// For advanced TCP configuration (connection timeout, keepalive, buffer
528    /// sizes), construct a [`TcpTransport`] directly and use [`Client::new()`].
529    ///
530    /// # Errors
531    ///
532    /// Returns an error if the configuration is invalid or the connection fails.
533    ///
534    /// # Example
535    ///
536    /// ```rust,no_run
537    /// use async_snmp::{Auth, ClientBuilder};
538    ///
539    /// # async fn example() -> async_snmp::Result<()> {
540    /// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
541    ///     .connect_tcp()
542    ///     .await?;
543    /// # Ok(())
544    /// # }
545    /// ```
546    pub async fn connect_tcp(self) -> Result<Client<TcpTransport>> {
547        self.validate()?;
548        let addr = self.resolve_target().await?;
549        let transport = TcpTransport::connect(addr).await?;
550        Ok(self.build_inner(transport))
551    }
552}
553
554/// Default SNMP port.
555const DEFAULT_PORT: u16 = 161;
556
557/// Split a target string into (host, port), defaulting to port 161.
558///
559/// Handles IPv4 (`192.168.1.1`), IPv4 with port (`192.168.1.1:162`),
560/// bare IPv6 (`fe80::1`), bracketed IPv6 (`[::1]`, `[::1]:162`),
561/// and hostnames (`switch.local`, `switch.local:162`).
562fn split_host_port(target: &str) -> (&str, u16) {
563    // Bracketed IPv6: [addr]:port or [addr]
564    if let Some(rest) = target.strip_prefix('[') {
565        if let Some((addr, port)) = rest.rsplit_once("]:")
566            && let Ok(p) = port.parse()
567        {
568            return (addr, p);
569        }
570        return (rest.trim_end_matches(']'), DEFAULT_PORT);
571    }
572
573    // IPv4 or hostname: last colon is the port separator, but only if the
574    // host part doesn't also contain colons (which would make it bare IPv6)
575    if let Some((host, port)) = target.rsplit_once(':')
576        && !host.contains(':')
577        && let Ok(p) = port.parse::<u16>()
578    {
579        return (host, p);
580    }
581
582    // No port found (bare IPv4, IPv6, or hostname)
583    (target, DEFAULT_PORT)
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::v3::{AuthProtocol, MasterKeys, PrivProtocol};
590
591    #[test]
592    fn test_builder_defaults() {
593        let builder = ClientBuilder::new("192.168.1.1:161", Auth::default());
594        assert_eq!(builder.target, "192.168.1.1:161");
595        assert_eq!(builder.timeout, DEFAULT_TIMEOUT);
596        assert_eq!(builder.retry.max_attempts, 3);
597        assert_eq!(builder.max_oids_per_request, DEFAULT_MAX_OIDS_PER_REQUEST);
598        assert_eq!(builder.max_repetitions, DEFAULT_MAX_REPETITIONS);
599        assert_eq!(builder.walk_mode, WalkMode::Auto);
600        assert_eq!(builder.oid_ordering, OidOrdering::Strict);
601        assert!(builder.max_walk_results.is_none());
602        assert!(builder.engine_cache.is_none());
603    }
604
605    #[test]
606    fn test_builder_with_options() {
607        let cache = Arc::new(EngineCache::new());
608        let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("private"))
609            .timeout(Duration::from_secs(10))
610            .retry(Retry::fixed(5, Duration::ZERO))
611            .max_oids_per_request(20)
612            .max_repetitions(50)
613            .walk_mode(WalkMode::GetNext)
614            .oid_ordering(OidOrdering::AllowNonIncreasing)
615            .max_walk_results(1000)
616            .engine_cache(cache.clone());
617
618        assert_eq!(builder.timeout, Duration::from_secs(10));
619        assert_eq!(builder.retry.max_attempts, 5);
620        assert_eq!(builder.max_oids_per_request, 20);
621        assert_eq!(builder.max_repetitions, 50);
622        assert_eq!(builder.walk_mode, WalkMode::GetNext);
623        assert_eq!(builder.oid_ordering, OidOrdering::AllowNonIncreasing);
624        assert_eq!(builder.max_walk_results, Some(1000));
625        assert!(builder.engine_cache.is_some());
626    }
627
628    #[test]
629    fn test_validate_community_ok() {
630        let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"));
631        assert!(builder.validate().is_ok());
632    }
633
634    #[test]
635    fn test_validate_usm_no_auth_no_priv_ok() {
636        let builder = ClientBuilder::new("192.168.1.1:161", Auth::usm("readonly"));
637        assert!(builder.validate().is_ok());
638    }
639
640    #[test]
641    fn test_validate_usm_auth_no_priv_ok() {
642        let builder = ClientBuilder::new(
643            "192.168.1.1:161",
644            Auth::usm("admin").auth(AuthProtocol::Sha256, "authpass"),
645        );
646        assert!(builder.validate().is_ok());
647    }
648
649    #[test]
650    fn test_validate_usm_auth_priv_ok() {
651        let builder = ClientBuilder::new(
652            "192.168.1.1:161",
653            Auth::usm("admin")
654                .auth(AuthProtocol::Sha256, "authpass")
655                .privacy(PrivProtocol::Aes128, "privpass"),
656        );
657        assert!(builder.validate().is_ok());
658    }
659
660    #[test]
661    fn test_validate_priv_without_auth_error() {
662        // Manually construct UsmAuth with priv but no auth
663        let usm = crate::client::UsmAuth {
664            username: "user".to_string(),
665            auth_protocol: None,
666            auth_password: None,
667            priv_protocol: Some(PrivProtocol::Aes128),
668            priv_password: Some("privpass".to_string()),
669            context_name: None,
670            master_keys: None,
671        };
672        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
673        let err = builder.validate().unwrap_err();
674        assert!(
675            matches!(*err, Error::Config(ref msg) if msg.contains("privacy requires authentication"))
676        );
677    }
678
679    #[test]
680    fn test_validate_auth_protocol_without_password_error() {
681        // Manually construct UsmAuth with auth protocol but no password
682        let usm = crate::client::UsmAuth {
683            username: "user".to_string(),
684            auth_protocol: Some(AuthProtocol::Sha256),
685            auth_password: None,
686            priv_protocol: None,
687            priv_password: None,
688            context_name: None,
689            master_keys: None,
690        };
691        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
692        let err = builder.validate().unwrap_err();
693        assert!(
694            matches!(*err, Error::Config(ref msg) if msg.contains("auth protocol requires password"))
695        );
696    }
697
698    #[test]
699    fn test_validate_priv_protocol_without_password_error() {
700        // Manually construct UsmAuth with priv protocol but no password
701        let usm = crate::client::UsmAuth {
702            username: "user".to_string(),
703            auth_protocol: Some(AuthProtocol::Sha256),
704            auth_password: Some("authpass".to_string()),
705            priv_protocol: Some(PrivProtocol::Aes128),
706            priv_password: None,
707            context_name: None,
708            master_keys: None,
709        };
710        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
711        let err = builder.validate().unwrap_err();
712        assert!(
713            matches!(*err, Error::Config(ref msg) if msg.contains("priv protocol requires password"))
714        );
715    }
716
717    #[test]
718    fn test_builder_with_usm_builder() {
719        // Test that UsmBuilder can be passed directly (via Into<Auth>)
720        let builder = ClientBuilder::new(
721            "192.168.1.1:161",
722            Auth::usm("admin").auth(AuthProtocol::Sha256, "pass"),
723        );
724        assert!(builder.validate().is_ok());
725    }
726
727    #[test]
728    fn test_validate_master_keys_bypass_auth_password() {
729        // When master keys are set, auth password is not required
730        let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpass");
731        let usm = crate::client::UsmAuth {
732            username: "user".to_string(),
733            auth_protocol: Some(AuthProtocol::Sha256),
734            auth_password: None, // No password
735            priv_protocol: None,
736            priv_password: None,
737            context_name: None,
738            master_keys: Some(master_keys),
739        };
740        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
741        assert!(builder.validate().is_ok());
742    }
743
744    #[test]
745    fn test_validate_master_keys_bypass_priv_password() {
746        // When master keys are set, priv password is not required
747        let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpass")
748            .with_privacy(PrivProtocol::Aes128, b"privpass");
749        let usm = crate::client::UsmAuth {
750            username: "user".to_string(),
751            auth_protocol: Some(AuthProtocol::Sha256),
752            auth_password: None, // No password
753            priv_protocol: Some(PrivProtocol::Aes128),
754            priv_password: None, // No password
755            context_name: None,
756            master_keys: Some(master_keys),
757        };
758        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
759        assert!(builder.validate().is_ok());
760    }
761
762    #[test]
763    fn test_split_host_port_ipv4_with_port() {
764        assert_eq!(split_host_port("192.168.1.1:162"), ("192.168.1.1", 162));
765    }
766
767    #[test]
768    fn test_split_host_port_ipv4_default() {
769        assert_eq!(split_host_port("192.168.1.1"), ("192.168.1.1", 161));
770    }
771
772    #[test]
773    fn test_split_host_port_ipv6_bare() {
774        assert_eq!(split_host_port("fe80::1"), ("fe80::1", 161));
775    }
776
777    #[test]
778    fn test_split_host_port_ipv6_loopback() {
779        assert_eq!(split_host_port("::1"), ("::1", 161));
780    }
781
782    #[test]
783    fn test_split_host_port_ipv6_bracketed_with_port() {
784        assert_eq!(split_host_port("[fe80::1]:162"), ("fe80::1", 162));
785    }
786
787    #[test]
788    fn test_split_host_port_ipv6_bracketed_default() {
789        assert_eq!(split_host_port("[::1]"), ("::1", 161));
790    }
791
792    #[test]
793    fn test_split_host_port_hostname() {
794        assert_eq!(split_host_port("switch.local"), ("switch.local", 161));
795    }
796
797    #[test]
798    fn test_split_host_port_hostname_with_port() {
799        assert_eq!(split_host_port("switch.local:162"), ("switch.local", 162));
800    }
801}