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}