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;
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 high-throughput polling).
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<(), Error> {
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()));
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()));
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()));
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()));
354        }
355
356        Ok(())
357    }
358
359    /// Resolve target address to SocketAddr.
360    fn resolve_target(&self) -> Result<SocketAddr, Error> {
361        self.target
362            .to_socket_addrs()
363            .map_err(|e| Error::Io {
364                target: None,
365                source: e,
366            })?
367            .next()
368            .ok_or_else(|| Error::Io {
369                target: None,
370                source: std::io::Error::new(
371                    std::io::ErrorKind::NotFound,
372                    "could not resolve address",
373                ),
374            })
375    }
376
377    /// Build ClientConfig from the builder settings.
378    fn build_config(&self) -> ClientConfig {
379        match &self.auth {
380            Auth::Community { version, community } => {
381                let snmp_version = match version {
382                    CommunityVersion::V1 => Version::V1,
383                    CommunityVersion::V2c => Version::V2c,
384                };
385                ClientConfig {
386                    version: snmp_version,
387                    community: Bytes::copy_from_slice(community.as_bytes()),
388                    timeout: self.timeout,
389                    retry: self.retry.clone(),
390                    max_oids_per_request: self.max_oids_per_request,
391                    v3_security: None,
392                    walk_mode: self.walk_mode,
393                    oid_ordering: self.oid_ordering,
394                    max_walk_results: self.max_walk_results,
395                    max_repetitions: self.max_repetitions,
396                }
397            }
398            Auth::Usm(usm) => {
399                let mut security =
400                    V3SecurityConfig::new(Bytes::copy_from_slice(usm.username.as_bytes()));
401
402                // Prefer master_keys over passwords if available
403                if let Some(ref master_keys) = usm.master_keys {
404                    security = security.with_master_keys(master_keys.clone());
405                } else {
406                    if let (Some(auth_proto), Some(auth_pass)) =
407                        (usm.auth_protocol, &usm.auth_password)
408                    {
409                        security = security.auth(auth_proto, auth_pass.as_bytes().to_vec());
410                    }
411
412                    if let (Some(priv_proto), Some(priv_pass)) =
413                        (usm.priv_protocol, &usm.priv_password)
414                    {
415                        security = security.privacy(priv_proto, priv_pass.as_bytes().to_vec());
416                    }
417                }
418
419                ClientConfig {
420                    version: Version::V3,
421                    community: Bytes::new(),
422                    timeout: self.timeout,
423                    retry: self.retry.clone(),
424                    max_oids_per_request: self.max_oids_per_request,
425                    v3_security: Some(security),
426                    walk_mode: self.walk_mode,
427                    oid_ordering: self.oid_ordering,
428                    max_walk_results: self.max_walk_results,
429                    max_repetitions: self.max_repetitions,
430                }
431            }
432        }
433    }
434
435    /// Build the client with the given transport.
436    fn build_inner<T: Transport>(self, transport: T) -> Client<T> {
437        let config = self.build_config();
438
439        if let Some(cache) = self.engine_cache {
440            Client::with_engine_cache(transport, config, cache)
441        } else {
442            Client::new(transport, config)
443        }
444    }
445
446    /// Connect via UDP (default).
447    ///
448    /// Creates a new UDP socket and connects to the target address. This is the
449    /// recommended connection method for most use cases due to UDP's lower
450    /// overhead compared to TCP.
451    ///
452    /// For high-throughput scenarios with many targets, consider using
453    /// [`UdpTransport`](crate::transport::UdpTransport) with [`build_with()`](Self::build_with).
454    ///
455    /// # Errors
456    ///
457    /// Returns an error if the configuration is invalid or the connection fails.
458    ///
459    /// # Example
460    ///
461    /// ```rust,no_run
462    /// use async_snmp::{Auth, ClientBuilder};
463    ///
464    /// # async fn example() -> async_snmp::Result<()> {
465    /// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
466    ///     .connect()
467    ///     .await?;
468    /// # Ok(())
469    /// # }
470    /// ```
471    pub async fn connect(self) -> Result<Client<UdpHandle>, Error> {
472        self.validate()?;
473        let addr = self.resolve_target()?;
474        // Use dual-stack socket for both IPv4 and IPv6 targets
475        let transport = UdpTransport::bind("[::]:0").await?;
476        let handle = transport.handle(addr);
477        Ok(self.build_inner(handle))
478    }
479
480    /// Build a client using a shared UDP transport.
481    ///
482    /// Creates a handle for the builder's target address from the given transport.
483    /// This is the recommended way to create multiple clients that share a socket.
484    ///
485    /// # Example
486    ///
487    /// ```rust,no_run
488    /// use async_snmp::{Auth, ClientBuilder};
489    /// use async_snmp::transport::UdpTransport;
490    ///
491    /// # async fn example() -> async_snmp::Result<()> {
492    /// let transport = UdpTransport::bind("0.0.0.0:0").await?;
493    ///
494    /// let client1 = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
495    ///     .build_with(&transport)?;
496    /// let client2 = ClientBuilder::new("192.168.1.2:161", Auth::v2c("public"))
497    ///     .build_with(&transport)?;
498    /// # Ok(())
499    /// # }
500    /// ```
501    pub fn build_with(self, transport: &UdpTransport) -> Result<Client<UdpHandle>, Error> {
502        self.validate()?;
503        let addr = self.resolve_target()?;
504        let handle = transport.handle(addr);
505        Ok(self.build_inner(handle))
506    }
507
508    /// Connect via TCP.
509    ///
510    /// Establishes a TCP connection to the target. Use this when:
511    /// - UDP is blocked by firewalls
512    /// - Messages exceed UDP's maximum datagram size
513    /// - Reliable delivery is required
514    ///
515    /// Note that TCP has higher overhead than UDP due to connection setup
516    /// and per-message framing.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if the configuration is invalid or the connection fails.
521    ///
522    /// # Example
523    ///
524    /// ```rust,no_run
525    /// use async_snmp::{Auth, ClientBuilder};
526    ///
527    /// # async fn example() -> async_snmp::Result<()> {
528    /// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
529    ///     .connect_tcp()
530    ///     .await?;
531    /// # Ok(())
532    /// # }
533    /// ```
534    pub async fn connect_tcp(self) -> Result<Client<TcpTransport>, Error> {
535        self.validate()?;
536        let addr = self.resolve_target()?;
537        let transport = TcpTransport::connect(addr).await?;
538        Ok(self.build_inner(transport))
539    }
540
541    /// Build a client with a custom transport.
542    ///
543    /// Use this method when you need:
544    /// - A custom transport implementation
545    /// - To reuse an existing transport
546    ///
547    /// For UDP shared transport usage, prefer [`build_with()`](Self::build_with).
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if the configuration is invalid.
552    ///
553    /// # Example
554    ///
555    /// ```rust,no_run
556    /// use async_snmp::{Auth, ClientBuilder};
557    /// use async_snmp::transport::UdpTransport;
558    ///
559    /// # async fn example() -> async_snmp::Result<()> {
560    /// // Create a transport
561    /// let transport = UdpTransport::bind("0.0.0.0:0").await?;
562    ///
563    /// // Create a handle for a specific target
564    /// let handle = transport.handle("192.168.1.1:161".parse().unwrap());
565    ///
566    /// // Build client with the handle
567    /// let client = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"))
568    ///     .build(handle)?;
569    /// # Ok(())
570    /// # }
571    /// ```
572    pub fn build<T: Transport>(self, transport: T) -> Result<Client<T>, Error> {
573        self.validate()?;
574        Ok(self.build_inner(transport))
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use crate::v3::{AuthProtocol, MasterKeys, PrivProtocol};
582
583    #[test]
584    fn test_builder_defaults() {
585        let builder = ClientBuilder::new("192.168.1.1:161", Auth::default());
586        assert_eq!(builder.target, "192.168.1.1:161");
587        assert_eq!(builder.timeout, Duration::from_secs(5));
588        assert_eq!(builder.retry.max_attempts, 3);
589        assert_eq!(builder.max_oids_per_request, 10);
590        assert_eq!(builder.max_repetitions, 25);
591        assert_eq!(builder.walk_mode, WalkMode::Auto);
592        assert_eq!(builder.oid_ordering, OidOrdering::Strict);
593        assert!(builder.max_walk_results.is_none());
594        assert!(builder.engine_cache.is_none());
595        assert!(builder.context_engine_id.is_none());
596    }
597
598    #[test]
599    fn test_builder_with_options() {
600        let cache = Arc::new(EngineCache::new());
601        let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("private"))
602            .timeout(Duration::from_secs(10))
603            .retry(Retry::fixed(5, Duration::ZERO))
604            .max_oids_per_request(20)
605            .max_repetitions(50)
606            .walk_mode(WalkMode::GetNext)
607            .oid_ordering(OidOrdering::AllowNonIncreasing)
608            .max_walk_results(1000)
609            .engine_cache(cache.clone())
610            .context_engine_id(vec![0x80, 0x00, 0x01]);
611
612        assert_eq!(builder.timeout, Duration::from_secs(10));
613        assert_eq!(builder.retry.max_attempts, 5);
614        assert_eq!(builder.max_oids_per_request, 20);
615        assert_eq!(builder.max_repetitions, 50);
616        assert_eq!(builder.walk_mode, WalkMode::GetNext);
617        assert_eq!(builder.oid_ordering, OidOrdering::AllowNonIncreasing);
618        assert_eq!(builder.max_walk_results, Some(1000));
619        assert!(builder.engine_cache.is_some());
620        assert_eq!(builder.context_engine_id, Some(vec![0x80, 0x00, 0x01]));
621    }
622
623    #[test]
624    fn test_validate_community_ok() {
625        let builder = ClientBuilder::new("192.168.1.1:161", Auth::v2c("public"));
626        assert!(builder.validate().is_ok());
627    }
628
629    #[test]
630    fn test_validate_usm_no_auth_no_priv_ok() {
631        let builder = ClientBuilder::new("192.168.1.1:161", Auth::usm("readonly"));
632        assert!(builder.validate().is_ok());
633    }
634
635    #[test]
636    fn test_validate_usm_auth_no_priv_ok() {
637        let builder = ClientBuilder::new(
638            "192.168.1.1:161",
639            Auth::usm("admin").auth(AuthProtocol::Sha256, "authpass"),
640        );
641        assert!(builder.validate().is_ok());
642    }
643
644    #[test]
645    fn test_validate_usm_auth_priv_ok() {
646        let builder = ClientBuilder::new(
647            "192.168.1.1:161",
648            Auth::usm("admin")
649                .auth(AuthProtocol::Sha256, "authpass")
650                .privacy(PrivProtocol::Aes128, "privpass"),
651        );
652        assert!(builder.validate().is_ok());
653    }
654
655    #[test]
656    fn test_validate_priv_without_auth_error() {
657        // Manually construct UsmAuth with priv but no auth
658        let usm = crate::client::UsmAuth {
659            username: "user".to_string(),
660            auth_protocol: None,
661            auth_password: None,
662            priv_protocol: Some(PrivProtocol::Aes128),
663            priv_password: Some("privpass".to_string()),
664            context_name: None,
665            master_keys: None,
666        };
667        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
668        let err = builder.validate().unwrap_err();
669        assert!(
670            matches!(err, Error::Config(msg) if msg.contains("privacy requires authentication"))
671        );
672    }
673
674    #[test]
675    fn test_validate_auth_protocol_without_password_error() {
676        // Manually construct UsmAuth with auth protocol but no password
677        let usm = crate::client::UsmAuth {
678            username: "user".to_string(),
679            auth_protocol: Some(AuthProtocol::Sha256),
680            auth_password: None,
681            priv_protocol: None,
682            priv_password: None,
683            context_name: None,
684            master_keys: None,
685        };
686        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
687        let err = builder.validate().unwrap_err();
688        assert!(
689            matches!(err, Error::Config(msg) if msg.contains("auth protocol requires password"))
690        );
691    }
692
693    #[test]
694    fn test_validate_priv_protocol_without_password_error() {
695        // Manually construct UsmAuth with priv protocol but no password
696        let usm = crate::client::UsmAuth {
697            username: "user".to_string(),
698            auth_protocol: Some(AuthProtocol::Sha256),
699            auth_password: Some("authpass".to_string()),
700            priv_protocol: Some(PrivProtocol::Aes128),
701            priv_password: None,
702            context_name: None,
703            master_keys: None,
704        };
705        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
706        let err = builder.validate().unwrap_err();
707        assert!(
708            matches!(err, Error::Config(msg) if msg.contains("priv protocol requires password"))
709        );
710    }
711
712    #[test]
713    fn test_builder_with_usm_builder() {
714        // Test that UsmBuilder can be passed directly (via Into<Auth>)
715        let builder = ClientBuilder::new(
716            "192.168.1.1:161",
717            Auth::usm("admin").auth(AuthProtocol::Sha256, "pass"),
718        );
719        assert!(builder.validate().is_ok());
720    }
721
722    #[test]
723    fn test_validate_master_keys_bypass_auth_password() {
724        // When master keys are set, auth password is not required
725        let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpass");
726        let usm = crate::client::UsmAuth {
727            username: "user".to_string(),
728            auth_protocol: Some(AuthProtocol::Sha256),
729            auth_password: None, // No password
730            priv_protocol: None,
731            priv_password: None,
732            context_name: None,
733            master_keys: Some(master_keys),
734        };
735        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
736        assert!(builder.validate().is_ok());
737    }
738
739    #[test]
740    fn test_validate_master_keys_bypass_priv_password() {
741        // When master keys are set, priv password is not required
742        let master_keys = MasterKeys::new(AuthProtocol::Sha256, b"authpass")
743            .with_privacy(PrivProtocol::Aes128, b"privpass");
744        let usm = crate::client::UsmAuth {
745            username: "user".to_string(),
746            auth_protocol: Some(AuthProtocol::Sha256),
747            auth_password: None, // No password
748            priv_protocol: Some(PrivProtocol::Aes128),
749            priv_password: None, // No password
750            context_name: None,
751            master_keys: Some(master_keys),
752        };
753        let builder = ClientBuilder::new("192.168.1.1:161", Auth::Usm(usm));
754        assert!(builder.validate().is_ok());
755    }
756}