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