use std::fmt;
use std::future::Future;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use super::Client;
use crate::client::retry::Retry;
use crate::client::walk::WalkOptions;
use crate::client::{Auth, ClientConfig};
use crate::error::{ConstructionStage, Error, Result};
use crate::transport::{
CommunityResponsePolicy, TcpTransport, Transport, UdpControl, UdpHandle, UdpTransport,
};
use crate::v3::{AuthoritativeEngine, DesSaltState, EngineCache};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Address(String),
HostPort(String, u16),
}
impl fmt::Display for Target {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Target::Address(addr) => f.write_str(addr),
Target::HostPort(host, port) => {
if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) {
write!(f, "[{host}]:{port}")
} else {
write!(f, "{host}:{port}")
}
}
}
}
}
impl From<&str> for Target {
fn from(s: &str) -> Self {
Target::Address(s.to_string())
}
}
impl From<String> for Target {
fn from(s: String) -> Self {
Target::Address(s)
}
}
impl From<&String> for Target {
fn from(s: &String) -> Self {
Target::Address(s.clone())
}
}
impl From<(&str, u16)> for Target {
fn from((host, port): (&str, u16)) -> Self {
Target::HostPort(host.to_string(), port)
}
}
impl From<(String, u16)> for Target {
fn from((host, port): (String, u16)) -> Self {
Target::HostPort(host, port)
}
}
impl From<SocketAddr> for Target {
fn from(addr: SocketAddr) -> Self {
Target::HostPort(addr.ip().to_string(), addr.port())
}
}
#[derive(Debug, Clone)]
pub struct ClientBuilder {
config: ClientConfig,
engine_cache: Option<Arc<EngineCache>>,
}
#[derive(Debug, Clone)]
pub struct TargetClientBuilder {
client: ClientBuilder,
target: Target,
construction_timeout: Duration,
strict_source: bool,
}
impl ClientBuilder {
pub fn new(auth: impl Into<Auth>) -> Self {
let config = ClientConfig {
auth: auth.into(),
..ClientConfig::default()
};
Self {
config,
engine_cache: None,
}
}
#[must_use]
pub fn target(self, target: impl Into<Target>) -> TargetClientBuilder {
TargetClientBuilder {
client: self,
target: target.into(),
construction_timeout: DEFAULT_CONSTRUCTION_TIMEOUT,
strict_source: false,
}
}
#[must_use]
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.config.request_timeout = timeout;
self
}
#[must_use]
pub fn exchange_timeout(mut self, timeout: Option<Duration>) -> Self {
self.config.exchange_timeout = timeout;
self
}
#[must_use]
pub fn send_timeout(mut self, timeout: Duration) -> Self {
self.config.send_timeout = timeout;
self
}
#[must_use]
pub fn retry(mut self, retry: impl Into<Retry>) -> Self {
self.config.retry = retry.into();
self
}
#[must_use]
pub fn max_oids_per_request(mut self, max: usize) -> Self {
self.config.max_oids_per_request = max;
self
}
#[must_use]
pub fn decode_config(mut self, config: crate::DecodeConfig) -> Self {
self.config.decode_config = config;
self
}
#[must_use]
pub fn response_shape_policy(mut self, policy: crate::client::ResponseShapePolicy) -> Self {
self.config.response_shape_policy = policy;
self
}
#[must_use]
pub fn walk_options(mut self, options: WalkOptions) -> Self {
self.config.walk_options = options;
self
}
#[must_use]
pub fn local_authoritative_engine(mut self, engine: AuthoritativeEngine) -> Self {
self.config.local_authoritative_engine = Some(engine);
self
}
#[must_use]
pub fn des_salt_state(mut self, state: DesSaltState) -> Self {
self.config.des_salt_state = Some(state);
self
}
#[must_use]
pub fn engine_cache(mut self, cache: Arc<EngineCache>) -> Self {
self.engine_cache = Some(cache);
self
}
#[must_use]
pub fn community_response_policy(mut self, policy: CommunityResponsePolicy) -> Self {
self.config.community_response_policy = policy;
self
}
#[must_use]
pub fn allow_unauthenticated_v3_time_correction(mut self, allow: bool) -> Self {
self.config.allow_unauthenticated_v3_time_correction = allow;
self
}
#[cfg(test)]
fn validate(&self) -> Result<()> {
self.build_config().validate()
}
fn validate_and_precompute(&mut self) -> Result<()> {
self.config.validate_and_precompute()?;
Ok(())
}
pub fn build_with_transport<T: Transport>(self, transport: T) -> Result<Client<T>> {
self.build_inner(transport)
}
#[cfg(test)]
fn build_config(&self) -> ClientConfig {
self.config.clone()
}
fn build_inner<T: Transport>(self, transport: T) -> Result<Client<T>> {
let config = self.config;
if let Some(cache) = self.engine_cache {
Client::with_engine_cache(transport, config, cache)
} else {
Client::new(transport, config)
}
}
}
impl TargetClientBuilder {
#[cfg(test)]
#[allow(dead_code, reason = "used by feature-specific builder tests")]
fn validate(&self) -> Result<()> {
self.client.validate()
}
#[cfg(test)]
#[allow(dead_code, reason = "used by feature-specific builder tests")]
fn build_config(&self) -> ClientConfig {
self.client.build_config()
}
#[must_use]
pub fn target(mut self, target: impl Into<Target>) -> Self {
self.target = target.into();
self
}
#[must_use]
pub fn construction_timeout(mut self, timeout: Duration) -> Self {
self.construction_timeout = timeout;
self
}
#[must_use]
pub fn strict_source(mut self, strict: bool) -> Self {
self.strict_source = strict;
self
}
#[must_use]
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.client = self.client.request_timeout(timeout);
self
}
#[must_use]
pub fn send_timeout(mut self, timeout: Duration) -> Self {
self.client = self.client.send_timeout(timeout);
self
}
#[must_use]
pub fn retry(mut self, retry: impl Into<Retry>) -> Self {
self.client = self.client.retry(retry);
self
}
#[must_use]
pub fn max_oids_per_request(mut self, max: usize) -> Self {
self.client = self.client.max_oids_per_request(max);
self
}
#[must_use]
pub fn decode_config(mut self, config: crate::DecodeConfig) -> Self {
self.client = self.client.decode_config(config);
self
}
#[must_use]
pub fn response_shape_policy(mut self, policy: crate::client::ResponseShapePolicy) -> Self {
self.client = self.client.response_shape_policy(policy);
self
}
#[must_use]
pub fn walk_options(mut self, options: WalkOptions) -> Self {
self.client = self.client.walk_options(options);
self
}
#[must_use]
pub fn local_authoritative_engine(mut self, engine: AuthoritativeEngine) -> Self {
self.client = self.client.local_authoritative_engine(engine);
self
}
#[must_use]
pub fn des_salt_state(mut self, state: DesSaltState) -> Self {
self.client = self.client.des_salt_state(state);
self
}
#[must_use]
pub fn engine_cache(mut self, cache: Arc<EngineCache>) -> Self {
self.client = self.client.engine_cache(cache);
self
}
#[must_use]
pub fn community_response_policy(mut self, policy: CommunityResponsePolicy) -> Self {
self.client = self.client.community_response_policy(policy);
self
}
#[must_use]
pub fn allow_unauthenticated_v3_time_correction(mut self, allow: bool) -> Self {
self.client = self.client.allow_unauthenticated_v3_time_correction(allow);
self
}
#[cfg(test)]
async fn resolve_targets(&self) -> Result<Vec<SocketAddr>> {
let deadline = ConstructionDeadline::new(&self.target, self.construction_timeout)?;
self.resolve_targets_with(&deadline, |host, port| async move {
tokio::net::lookup_host((host.as_str(), port))
.await
.map(|addresses| addresses.collect())
.map_err(|error| {
Error::Config(format!("could not resolve address '{host}': {error}").into())
.boxed()
})
})
.await
}
async fn resolve_targets_with<F, Fut>(
&self,
deadline: &ConstructionDeadline,
resolver: F,
) -> Result<Vec<SocketAddr>>
where
F: FnOnce(String, u16) -> Fut,
Fut: Future<Output = Result<Vec<SocketAddr>>>,
{
let (host, port) = match &self.target {
Target::Address(addr) => split_host_port(addr),
Target::HostPort(host, port) => (host.as_str(), *port),
};
let host = host.to_owned();
let original_target = self.target.clone();
deadline
.run(ConstructionStage::Resolve, async move {
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return Ok(vec![SocketAddr::new(ip, port)]);
}
let addresses = resolver(host, port).await?;
if addresses.is_empty() {
return Err(Error::Config(
format!("could not resolve address '{original_target}'").into(),
)
.boxed());
}
Ok(addresses)
})
.await
}
fn select_udp_handle(
transport: &UdpTransport,
target: &Target,
candidates: &[SocketAddr],
) -> Result<UdpHandle> {
for candidate in candidates {
if let Ok(handle) = transport.handle(*candidate) {
return Ok(handle);
}
}
Err(Error::Config(
format!(
"no resolved address for '{target}' is compatible with UDP socket {}",
transport.local_addr()
)
.into(),
)
.boxed())
}
pub async fn connect(self) -> Result<Client<UdpHandle>> {
self.connect_with_control()
.await
.map(|(client, _control)| client)
}
pub async fn connect_with_control(self) -> Result<(Client<UdpHandle>, UdpControl)> {
self.connect_with_control_using(
|host, port| async move {
tokio::net::lookup_host((host.as_str(), port))
.await
.map(|addresses| addresses.collect())
.map_err(|error| {
Error::Config(format!("could not resolve address '{host}': {error}").into())
.boxed()
})
},
|bind_addr| async move { UdpTransport::bind(bind_addr).await },
)
.await
}
async fn connect_with_control_using<R, RFut, B, BFut>(
mut self,
resolver: R,
binder: B,
) -> Result<(Client<UdpHandle>, UdpControl)>
where
R: FnOnce(String, u16) -> RFut,
RFut: Future<Output = Result<Vec<SocketAddr>>>,
B: FnOnce(&'static str) -> BFut,
BFut: Future<Output = Result<UdpTransport>>,
{
self.client.validate_and_precompute()?;
let deadline = ConstructionDeadline::new(&self.target, self.construction_timeout)?;
let addr = self.resolve_targets_with(&deadline, resolver).await?[0];
let bind_addr = if addr.is_ipv6() {
"[::]:0"
} else {
"0.0.0.0:0"
};
let transport = deadline
.run(ConstructionStage::Bind, binder(bind_addr))
.await?;
let control = transport.control();
let handle = transport.handle(addr)?.strict_source(self.strict_source);
let client = self.client.build_inner(handle)?;
Ok((client, control))
}
pub async fn build_with(self, transport: &UdpTransport) -> Result<Client<UdpHandle>> {
self.build_with_resolver(transport, |host, port| async move {
tokio::net::lookup_host((host.as_str(), port))
.await
.map(|addresses| addresses.collect())
.map_err(|error| {
Error::Config(format!("could not resolve address '{host}': {error}").into())
.boxed()
})
})
.await
}
async fn build_with_resolver<R, RFut>(
mut self,
transport: &UdpTransport,
resolver: R,
) -> Result<Client<UdpHandle>>
where
R: FnOnce(String, u16) -> RFut,
RFut: Future<Output = Result<Vec<SocketAddr>>>,
{
self.client.validate_and_precompute()?;
let deadline = ConstructionDeadline::new(&self.target, self.construction_timeout)?;
let candidates = self.resolve_targets_with(&deadline, resolver).await?;
let handle = Self::select_udp_handle(transport, &self.target, &candidates)?
.strict_source(self.strict_source);
self.client.build_inner(handle)
}
pub async fn connect_tcp(self) -> Result<Client<TcpTransport>> {
self.connect_tcp_with(
|host, port| async move {
tokio::net::lookup_host((host.as_str(), port))
.await
.map(|addresses| addresses.collect())
.map_err(|error| {
Error::Config(format!("could not resolve address '{host}': {error}").into())
.boxed()
})
},
|address| async move { TcpTransport::connect(address).await },
)
.await
}
async fn connect_tcp_with<R, RFut, C, CFut>(
mut self,
resolver: R,
mut connector: C,
) -> Result<Client<TcpTransport>>
where
R: FnOnce(String, u16) -> RFut,
RFut: Future<Output = Result<Vec<SocketAddr>>>,
C: FnMut(SocketAddr) -> CFut,
CFut: Future<Output = Result<TcpTransport>>,
{
self.client.validate_and_precompute()?;
let deadline = ConstructionDeadline::new(&self.target, self.construction_timeout)?;
let candidates = self.resolve_targets_with(&deadline, resolver).await?;
let mut last_error = None;
for address in candidates {
match deadline
.run(ConstructionStage::Connect, connector(address))
.await
{
Ok(transport) => return self.client.build_inner(transport),
Err(error) if matches!(*error, Error::ConstructionTimeout { .. }) => {
return Err(error);
}
Err(error) => last_error = Some(error),
}
}
match last_error {
Some(error) => Err(error),
None => Err(Error::Config(
format!(
"could not connect to any resolved address for '{}'",
self.target
)
.into(),
)
.boxed()),
}
}
}
struct ConstructionDeadline {
target: Target,
started: tokio::time::Instant,
deadline: tokio::time::Instant,
}
impl ConstructionDeadline {
fn new(target: &Target, timeout: Duration) -> Result<Self> {
let started = tokio::time::Instant::now();
let deadline = started.checked_add(timeout).ok_or_else(|| {
Error::Config("construction timeout exceeds the representable deadline".into()).boxed()
})?;
Ok(Self {
target: target.clone(),
started,
deadline,
})
}
async fn run<T, F>(&self, stage: ConstructionStage, future: F) -> Result<T>
where
F: Future<Output = Result<T>>,
{
if tokio::time::Instant::now() >= self.deadline {
return Err(self.timeout_error(stage));
}
tokio::time::timeout_at(self.deadline, future)
.await
.map_err(|_| self.timeout_error(stage))?
}
fn timeout_error(&self, stage: ConstructionStage) -> Box<Error> {
Error::ConstructionTimeout {
target: self.target.clone(),
stage,
elapsed: self.started.elapsed(),
}
.boxed()
}
}
pub const DEFAULT_CONSTRUCTION_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_PORT: u16 = 161;
fn split_host_port(target: &str) -> (&str, u16) {
if let Some(rest) = target.strip_prefix('[') {
if let Some((addr, port)) = rest.rsplit_once("]:")
&& let Ok(p) = port.parse()
{
return (addr, p);
}
return (rest.trim_end_matches(']'), DEFAULT_PORT);
}
if let Some((host, port)) = target.rsplit_once(':')
&& !host.contains(':')
&& let Ok(p) = port.parse::<u16>()
{
return (host, p);
}
(target, DEFAULT_PORT)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(any(feature = "crypto-rustcrypto", feature = "crypto-fips"))]
use crate::v3::MasterKeys;
use crate::v3::UsmConfig;
#[cfg(any(feature = "crypto-rustcrypto", feature = "crypto-fips"))]
use crate::v3::{AuthProtocol, PrivProtocol};
use crate::{DEFAULT_MAX_OIDS_PER_REQUEST, DEFAULT_REQUEST_TIMEOUT, DEFAULT_SEND_TIMEOUT};
#[test]
fn test_builder_defaults() {
let builder = ClientBuilder::new(Auth::default());
assert_eq!(builder.config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
assert_eq!(builder.config.send_timeout, DEFAULT_SEND_TIMEOUT);
assert_eq!(
ClientConfig::default().request_timeout,
Duration::from_secs(5)
);
assert_eq!(DEFAULT_REQUEST_TIMEOUT, Duration::from_secs(5));
assert_eq!(DEFAULT_SEND_TIMEOUT, Duration::from_secs(5));
assert_eq!(DEFAULT_CONSTRUCTION_TIMEOUT, Duration::from_secs(5));
assert_eq!(builder.config.retry.retries(), 3);
assert_eq!(
builder.config.max_oids_per_request,
DEFAULT_MAX_OIDS_PER_REQUEST
);
assert_eq!(
builder.config.response_shape_policy,
crate::client::ResponseShapePolicy::Compatible
);
assert_eq!(builder.config.walk_options, WalkOptions::default());
assert!(builder.engine_cache.is_none());
assert_eq!(
builder.config.community_response_policy,
CommunityResponsePolicy::Exact
);
assert_eq!(
builder.build_config().community_response_policy,
ClientConfig::default().community_response_policy
);
assert!(!builder.config.allow_unauthenticated_v3_time_correction);
let target = builder.target("192.168.1.1:161");
assert!(matches!(target.target, Target::Address(ref s) if s == "192.168.1.1:161"));
assert_eq!(target.construction_timeout, DEFAULT_CONSTRUCTION_TIMEOUT);
assert!(!target.strict_source);
}
#[test]
fn test_builder_with_options() {
let cache = Arc::new(EngineCache::new());
let builder = ClientBuilder::new(Auth::v2c("private"))
.request_timeout(Duration::from_secs(10))
.send_timeout(Duration::from_secs(8))
.retry(Retry::fixed(5, Duration::ZERO).unwrap())
.max_oids_per_request(20)
.response_shape_policy(crate::client::ResponseShapePolicy::Strict)
.walk_options(WalkOptions {
method: crate::WalkMethod::GetNext,
max_repetitions: 50,
ordering: crate::OidOrdering::AllowNonIncreasing,
result_limit: Some(1000),
})
.engine_cache(cache.clone())
.target("192.168.1.1:161")
.construction_timeout(Duration::from_secs(7))
.strict_source(true)
.community_response_policy(CommunityResponsePolicy::AllowMismatchFromTarget)
.allow_unauthenticated_v3_time_correction(true);
assert_eq!(
builder.client.config.request_timeout,
Duration::from_secs(10)
);
assert_eq!(builder.client.config.send_timeout, Duration::from_secs(8));
assert_eq!(
builder.client.build_config().send_timeout,
Duration::from_secs(8)
);
assert_eq!(builder.construction_timeout, Duration::from_secs(7));
assert_eq!(builder.client.config.retry.retries(), 5);
assert_eq!(builder.client.config.max_oids_per_request, 20);
assert_eq!(
builder.client.build_config().response_shape_policy,
crate::client::ResponseShapePolicy::Strict
);
assert_eq!(
builder.client.config.walk_options,
WalkOptions {
method: crate::WalkMethod::GetNext,
max_repetitions: 50,
ordering: crate::OidOrdering::AllowNonIncreasing,
result_limit: Some(1000),
}
);
assert!(builder.client.engine_cache.is_some());
assert!(builder.strict_source);
assert_eq!(
builder.client.config.community_response_policy,
CommunityResponsePolicy::AllowMismatchFromTarget
);
assert!(
builder
.client
.config
.allow_unauthenticated_v3_time_correction
);
assert!(
builder
.client
.build_config()
.allow_unauthenticated_v3_time_correction
);
}
#[tokio::test]
async fn tcp_connect_tries_later_resolved_addresses() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let reachable = listener.local_addr().unwrap();
let unreachable = "192.0.2.1:161".parse().unwrap();
let attempts = Arc::new(std::sync::Mutex::new(Vec::new()));
let connector_attempts = Arc::clone(&attempts);
let client = Client::builder("device.example", Auth::v2c("public"))
.connect_tcp_with(
move |_, _| async move { Ok(vec![unreachable, reachable]) },
move |address| {
let connector_attempts = Arc::clone(&connector_attempts);
async move {
connector_attempts.lock().unwrap().push(address);
if address == unreachable {
return Err(Error::Network {
target: address,
source: std::io::Error::from(std::io::ErrorKind::ConnectionRefused),
}
.boxed());
}
TcpTransport::connect(address).await
}
},
)
.await
.unwrap();
assert_eq!(client.peer_addr(), reachable);
assert_eq!(*attempts.lock().unwrap(), vec![unreachable, reachable]);
}
#[tokio::test]
async fn tcp_connect_returns_last_candidate_error() {
let first = "192.0.2.1:161".parse().unwrap();
let last = "192.0.2.2:161".parse().unwrap();
let attempts = Arc::new(std::sync::Mutex::new(Vec::new()));
let connector_attempts = Arc::clone(&attempts);
let error = Client::builder("device.example", Auth::v2c("public"))
.connect_tcp_with(
move |_, _| async move { Ok(vec![first, last]) },
move |address| {
let connector_attempts = Arc::clone(&connector_attempts);
async move {
connector_attempts.lock().unwrap().push(address);
Err::<TcpTransport, _>(
Error::Network {
target: address,
source: std::io::Error::from(std::io::ErrorKind::ConnectionRefused),
}
.boxed(),
)
}
},
)
.await
.err()
.expect("all connection attempts must fail");
assert!(matches!(*error, Error::Network { target, .. } if target == last));
assert_eq!(*attempts.lock().unwrap(), vec![first, last]);
}
#[tokio::test(start_paused = true)]
async fn pending_tcp_connect_uses_construction_deadline_and_diagnostics() {
let future = Client::builder("device.example:1161", Auth::v2c("public"))
.request_timeout(Duration::from_secs(91))
.construction_timeout(Duration::from_secs(5))
.connect_tcp_with(
|_, _| async { Ok(vec!["192.0.2.1:1161".parse().unwrap()]) },
|_| std::future::pending::<Result<TcpTransport>>(),
);
let task = tokio::spawn(future);
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(5)).await;
let error = task
.await
.unwrap()
.err()
.expect("construction must time out");
match *error {
Error::ConstructionTimeout {
target,
stage,
elapsed,
} => {
assert_eq!(target, Target::Address("device.example:1161".to_owned()));
assert_eq!(stage, ConstructionStage::Connect);
assert_eq!(elapsed, Duration::from_secs(5));
}
other => panic!("expected construction timeout, got {other:?}"),
}
}
#[tokio::test(start_paused = true)]
async fn resolution_and_tcp_connect_share_one_total_budget() {
let future = Client::builder("device.example", Auth::v2c("public"))
.construction_timeout(Duration::from_secs(5))
.connect_tcp_with(
|_, _| async {
tokio::time::sleep(Duration::from_secs(4)).await;
Ok(vec!["192.0.2.1:161".parse().unwrap()])
},
|_| std::future::pending::<Result<TcpTransport>>(),
);
let task = tokio::spawn(future);
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(4)).await;
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(1)).await;
let error = task
.await
.unwrap()
.err()
.expect("construction must time out");
assert!(matches!(
*error,
Error::ConstructionTimeout {
stage: ConstructionStage::Connect,
elapsed,
..
} if elapsed == Duration::from_secs(5)
));
}
#[tokio::test(start_paused = true)]
async fn udp_resolution_uses_construction_deadline() {
let future = Client::builder("device.example", Auth::v2c("public"))
.construction_timeout(Duration::from_secs(3))
.connect_with_control_using(
|_, _| std::future::pending::<Result<Vec<SocketAddr>>>(),
|_| async { panic!("bind must not begin while resolution is pending") },
);
let task = tokio::spawn(future);
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(3)).await;
let error = task
.await
.unwrap()
.err()
.expect("construction must time out");
assert!(matches!(
*error,
Error::ConstructionTimeout {
stage: ConstructionStage::Resolve,
elapsed,
..
} if elapsed == Duration::from_secs(3)
));
}
#[tokio::test(start_paused = true)]
async fn udp_bind_uses_remaining_construction_deadline() {
let future = Client::builder("192.0.2.1", Auth::v2c("public"))
.construction_timeout(Duration::from_secs(2))
.connect_with_control_using(
|_, _| async { panic!("numeric targets must not invoke the resolver") },
|_| std::future::pending::<Result<UdpTransport>>(),
);
let task = tokio::spawn(future);
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(2)).await;
let error = task
.await
.unwrap()
.err()
.expect("construction must time out");
assert!(matches!(
*error,
Error::ConstructionTimeout {
stage: ConstructionStage::Bind,
..
}
));
}
#[tokio::test]
async fn local_tcp_listener_connects_with_construction_timeout() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let accept = tokio::spawn(async move { listener.accept().await.unwrap() });
let client = Client::builder(address, Auth::v2c("public"))
.construction_timeout(Duration::from_secs(5))
.connect_tcp()
.await
.unwrap();
assert_eq!(client.peer_addr(), address);
accept.await.unwrap();
}
#[tokio::test]
async fn unrepresentable_construction_timeout_precedes_resolution() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let resolver_calls = Arc::clone(&calls);
let error = Client::builder("device.example", Auth::v2c("public"))
.construction_timeout(Duration::MAX)
.connect_tcp_with(
move |_, _| {
resolver_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
async { Ok(vec!["127.0.0.1:161".parse().unwrap()]) }
},
|_| std::future::pending::<Result<TcpTransport>>(),
)
.await
.err()
.expect("unrepresentable timeout must fail");
assert!(matches!(*error, Error::Config(_)));
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
}
#[tokio::test]
async fn zero_construction_timeout_is_immediate() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let resolver_calls = Arc::clone(&calls);
let error = Client::builder("device.example", Auth::v2c("public"))
.construction_timeout(Duration::ZERO)
.connect_tcp_with(
move |_, _| {
resolver_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
async { Ok(vec!["127.0.0.1:161".parse().unwrap()]) }
},
|_| std::future::pending::<Result<TcpTransport>>(),
)
.await
.err()
.expect("zero timeout must fail");
assert!(matches!(
*error,
Error::ConstructionTimeout {
target: Target::Address(ref target),
stage: ConstructionStage::Resolve,
..
} if target == "device.example"
));
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
}
#[test]
fn request_and_construction_settings_are_independent() {
let builder = Client::builder("192.0.2.1", Auth::v2c("public"))
.request_timeout(Duration::from_secs(11))
.construction_timeout(Duration::from_secs(17));
assert_eq!(
builder.client.build_config().request_timeout,
Duration::from_secs(11)
);
assert_eq!(builder.construction_timeout, Duration::from_secs(17));
}
#[test]
fn test_validate_community_ok() {
let builder = ClientBuilder::new(Auth::v2c("public"));
assert!(builder.validate().is_ok());
}
#[test]
fn test_validate_zero_max_oids_per_request_error() {
let builder = ClientBuilder::new(Auth::v2c("public")).max_oids_per_request(0);
let err = builder.validate().unwrap_err();
assert!(matches!(
*err,
Error::Config(ref msg) if msg.contains("max_oids_per_request must be greater than 0")
));
}
#[derive(Clone)]
struct CustomTransport {
calls: Arc<std::sync::atomic::AtomicUsize>,
}
impl Transport for CustomTransport {
async fn send(&self, _data: &[u8]) -> Result<()> {
self.calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
async fn request_with<T, F>(
&self,
_data: &[u8],
_registration: crate::transport::RequestRegistration,
_validate: F,
) -> Result<T>
where
T: Send,
F: FnMut(bytes::Bytes, std::net::SocketAddr) -> Result<crate::transport::Candidate<T>>
+ Send,
{
self.calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Err(Error::Config("unexpected custom transport receive".into()).boxed())
}
fn peer_addr(&self) -> std::net::SocketAddr {
self.calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
"127.0.0.1:161".parse().unwrap()
}
fn local_addr(&self) -> std::net::SocketAddr {
self.calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
"127.0.0.1:0".parse().unwrap()
}
fn is_reliable(&self) -> bool {
true
}
}
#[test]
fn test_build_with_transport() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let transport = CustomTransport {
calls: Arc::clone(&calls),
};
let client = ClientBuilder::new(Auth::v2c("private"))
.request_timeout(Duration::from_secs(9))
.retry(Retry::none())
.max_oids_per_request(7)
.walk_options(WalkOptions {
method: crate::WalkMethod::GetNext,
max_repetitions: 11,
ordering: crate::OidOrdering::AllowNonIncreasing,
result_limit: Some(99),
})
.build_with_transport(transport.clone())
.expect("valid custom-transport client");
assert_eq!(client.inner.config.request_timeout, Duration::from_secs(9));
assert_eq!(client.inner.config.retry.retries(), 0);
assert_eq!(client.inner.config.max_oids_per_request, 7);
assert_eq!(
client.inner.config.walk_options,
WalkOptions {
method: crate::WalkMethod::GetNext,
max_repetitions: 11,
ordering: crate::OidOrdering::AllowNonIncreasing,
result_limit: Some(99),
}
);
assert!(matches!(
&client.inner.config.auth,
Auth::Community {
version: crate::CommunityVersion::V2c,
community,
} if community.matches(b"private")
));
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
let invalid = ClientBuilder::new(Auth::v2c("public"))
.max_oids_per_request(0)
.build_with_transport(transport.clone());
assert!(matches!(invalid, Err(ref error) if matches!(&**error, Error::Config(_))));
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
let invalid_usm =
ClientBuilder::new(Auth::Usm(UsmConfig::new(""))).build_with_transport(transport);
assert!(matches!(
invalid_usm,
Err(ref error)
if matches!(&**error, Error::Config(message) if message.contains("USM username"))
));
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
}
#[tokio::test]
async fn preconfigured_custom_and_builtin_transports_cover_all_versions() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let custom = CustomTransport {
calls: Arc::clone(&calls),
};
let endpoint = UdpTransport::bind("127.0.0.1:0").await.unwrap();
let peer = "127.0.0.1:161".parse().unwrap();
for (auth, expected) in [
(Auth::v1("private"), crate::Version::V1),
(Auth::v2c("public"), crate::Version::V2c),
(Auth::usm("operator"), crate::Version::V3),
] {
let custom_client = ClientBuilder::new(auth.clone())
.build_with_transport(custom.clone())
.unwrap();
assert_eq!(custom_client.version(), expected);
let builtin = crate::BuiltinTransport::from(endpoint.handle(peer).unwrap());
let builtin_client = ClientBuilder::new(auth)
.build_with_transport(builtin)
.unwrap();
assert_eq!(builtin_client.version(), expected);
}
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
}
#[test]
fn client_builder_reuse_and_target_override_preserve_last_setting() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let base = ClientBuilder::new(Auth::v2c("public")).request_timeout(Duration::from_secs(7));
let custom = base
.clone()
.build_with_transport(CustomTransport {
calls: Arc::clone(&calls),
})
.unwrap();
assert_eq!(custom.inner.config.request_timeout, Duration::from_secs(7));
let targeted = base
.target("192.0.2.1:161")
.request_timeout(Duration::from_secs(11))
.target("192.0.2.2:1161");
assert_eq!(
targeted.client.config.request_timeout,
Duration::from_secs(11)
);
assert_eq!(
targeted.target,
Target::Address("192.0.2.2:1161".to_owned())
);
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
}
#[tokio::test]
async fn target_resolution_errors_are_confined_to_builtin_construction() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
ClientBuilder::new(Auth::v2c("public"))
.build_with_transport(CustomTransport {
calls: Arc::clone(&calls),
})
.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
let endpoint = UdpTransport::bind("127.0.0.1:0").await.unwrap();
let error = ClientBuilder::new(Auth::v2c("public"))
.target("unresolvable.invalid")
.build_with_resolver(&endpoint, |_, _| async {
Err(Error::Config("synthetic resolution failure".into()).boxed())
})
.await
.err()
.expect("synthetic resolution error must be returned");
assert!(error.to_string().contains("synthetic resolution failure"));
}
#[test]
fn test_validate_local_authoritative_engine() {
let engine = AuthoritativeEngine::install(b"valid-engine".to_vec(), |_| {
Ok::<(), std::convert::Infallible>(())
})
.unwrap();
let valid = ClientBuilder::new(Auth::usm("trapuser")).local_authoritative_engine(engine);
assert!(valid.validate().is_ok());
}
#[test]
fn test_validate_usm_no_auth_no_priv_ok() {
let builder = ClientBuilder::new(Auth::usm("readonly"));
assert!(builder.validate().is_ok());
}
#[cfg(any(feature = "crypto-rustcrypto", feature = "crypto-fips"))]
#[test]
fn test_validate_usm_auth_no_priv_ok() {
let builder = Client::builder(
"192.168.1.1:161",
crate::UsmConfig::new("admin")
.auth(AuthProtocol::Sha256, "authpass")
.unwrap(),
);
assert!(builder.validate().is_ok());
}
#[cfg(any(feature = "crypto-rustcrypto", feature = "crypto-fips"))]
#[test]
fn test_validate_usm_auth_priv_ok() {
let builder = Client::builder(
"192.168.1.1:161",
crate::UsmConfig::new("admin")
.auth_priv(
AuthProtocol::Sha256,
"authpass",
PrivProtocol::Aes128,
"privpass",
)
.unwrap(),
);
assert!(builder.validate().is_ok());
}
#[cfg(any(feature = "crypto-rustcrypto", feature = "crypto-fips"))]
#[test]
fn test_builder_with_usm_config() {
let builder = Client::builder(
"192.168.1.1:161",
crate::UsmConfig::new("admin")
.auth(AuthProtocol::Sha256, "password")
.unwrap(),
);
assert!(builder.validate().is_ok());
}
#[cfg(any(feature = "crypto-rustcrypto", feature = "crypto-fips"))]
#[test]
fn test_validate_master_keys_configs() {
let auth_only = MasterKeys::new(AuthProtocol::Sha256, b"authpass").unwrap();
let builder = Client::builder(
"192.168.1.1:161",
crate::UsmConfig::new("user")
.with_master_keys(auth_only)
.unwrap(),
);
assert!(builder.validate().is_ok());
let auth_priv = MasterKeys::new(AuthProtocol::Sha256, b"authpass")
.unwrap()
.with_privacy(PrivProtocol::Aes128, b"privpass")
.unwrap();
let builder = Client::builder(
"192.168.1.1:161",
crate::UsmConfig::new("user")
.with_master_keys(auth_priv)
.unwrap(),
);
assert!(builder.validate().is_ok());
}
#[cfg(any(feature = "crypto-rustcrypto", feature = "crypto-fips"))]
#[test]
fn test_build_config_preserves_v3_context_name() {
let builder = Client::builder(
"192.168.1.1:161",
crate::UsmConfig::new("admin")
.auth(AuthProtocol::Sha256, "authpass")
.unwrap()
.context_name("vlan100"),
);
let config = builder.build_config();
let Auth::Usm(security) = config.auth else {
panic!("expected v3 security config to be built");
};
assert_eq!(security.configured_context_name().as_ref(), b"vlan100");
}
#[test]
fn test_builder_with_host_port_tuple() {
let builder = Client::builder(("fe80::1", 161), Auth::default());
assert!(matches!(
builder.target,
Target::HostPort(ref h, 161) if h == "fe80::1"
));
}
#[test]
fn test_builder_with_string_host_port_tuple() {
let builder = Client::builder(("switch.local".to_string(), 162), Auth::v2c("public"));
assert!(matches!(
builder.target,
Target::HostPort(ref h, 162) if h == "switch.local"
));
}
#[test]
fn test_target_from_str() {
let t: Target = "192.168.1.1:161".into();
assert!(matches!(t, Target::Address(ref s) if s == "192.168.1.1:161"));
}
#[test]
fn test_target_from_tuple() {
let t: Target = ("fe80::1", 161).into();
assert!(matches!(t, Target::HostPort(ref h, 161) if h == "fe80::1"));
}
#[test]
fn test_target_from_socket_addr() {
let addr: SocketAddr = "192.168.1.1:162".parse().unwrap();
let t: Target = addr.into();
assert!(matches!(t, Target::HostPort(ref h, 162) if h == "192.168.1.1"));
}
#[test]
fn test_target_display() {
let t: Target = "192.168.1.1:161".into();
assert_eq!(t.to_string(), "192.168.1.1:161");
let t: Target = ("fe80::1", 161).into();
assert_eq!(t.to_string(), "[fe80::1]:161");
let addr: SocketAddr = "[::1]:162".parse().unwrap();
let t: Target = addr.into();
assert_eq!(t.to_string(), "[::1]:162");
}
#[tokio::test]
async fn test_udp_candidate_selection_skips_incompatible_family() {
let transport = UdpTransport::bind("127.0.0.1:0").await.unwrap();
let target = Target::from("example.invalid");
let candidates = [
"[2001:db8::1]:161".parse().unwrap(),
"192.0.2.1:161".parse().unwrap(),
];
let handle =
TargetClientBuilder::select_udp_handle(&transport, &target, &candidates).unwrap();
assert_eq!(handle.peer_addr(), candidates[1]);
}
#[tokio::test]
async fn test_udp_candidate_selection_rejects_ipv6_only_for_ipv4_transport() {
let transport = UdpTransport::bind("127.0.0.1:0").await.unwrap();
let target = Target::from("example.invalid");
let candidates = [
"[2001:db8::1]:161".parse().unwrap(),
"[2001:db8::2]:161".parse().unwrap(),
];
let error = TargetClientBuilder::select_udp_handle(&transport, &target, &candidates)
.err()
.expect("IPv6-only candidates must be rejected for an IPv4 transport");
assert!(matches!(*error, Error::Config(_)));
assert!(error.to_string().contains("no resolved address"));
}
#[tokio::test]
async fn test_udp_candidate_selection_normalizes_mapped_ipv6() {
let transport = UdpTransport::bind("127.0.0.1:0").await.unwrap();
let target = Target::from("example.invalid");
let candidates = ["[::ffff:192.0.2.1]:161".parse().unwrap()];
let handle =
TargetClientBuilder::select_udp_handle(&transport, &target, &candidates).unwrap();
assert_eq!(handle.peer_addr(), "192.0.2.1:161".parse().unwrap());
}
#[tokio::test]
async fn test_build_with_rejects_explicit_native_ipv6_for_ipv4_transport() {
let transport = UdpTransport::bind("127.0.0.1:0").await.unwrap();
let error = Client::builder("[2001:db8::1]:161", Auth::v2c("public"))
.build_with(&transport)
.await
.err()
.expect("native IPv6 target must fail during client construction");
assert!(matches!(*error, Error::Config(_)));
assert!(error.to_string().contains("no resolved address"));
}
#[tokio::test]
async fn test_resolve_target_socket_addr() {
let addr: SocketAddr = "10.0.0.1:162".parse().unwrap();
let builder = Client::builder(addr, Auth::default());
let resolved = builder.resolve_targets().await.unwrap();
assert_eq!(resolved, vec![addr]);
}
#[tokio::test]
async fn test_resolve_target_host_port_ipv4() {
let builder = Client::builder(("192.168.1.1", 162), Auth::default());
let addrs = builder.resolve_targets().await.unwrap();
assert_eq!(addrs, vec!["192.168.1.1:162".parse().unwrap()]);
}
#[tokio::test]
async fn test_resolve_target_host_port_ipv6() {
let builder = Client::builder(("::1", 161), Auth::default());
let addrs = builder.resolve_targets().await.unwrap();
assert_eq!(addrs, vec!["[::1]:161".parse().unwrap()]);
}
#[tokio::test]
async fn test_resolve_target_string_still_works() {
let builder = Client::builder("10.0.0.1:162", Auth::default());
let addrs = builder.resolve_targets().await.unwrap();
assert_eq!(addrs, vec!["10.0.0.1:162".parse().unwrap()]);
}
#[test]
fn test_split_host_port_ipv4_with_port() {
assert_eq!(split_host_port("192.168.1.1:162"), ("192.168.1.1", 162));
}
#[test]
fn test_split_host_port_ipv4_default() {
assert_eq!(split_host_port("192.168.1.1"), ("192.168.1.1", 161));
}
#[test]
fn test_split_host_port_ipv6_bare() {
assert_eq!(split_host_port("fe80::1"), ("fe80::1", 161));
}
#[test]
fn test_split_host_port_ipv6_loopback() {
assert_eq!(split_host_port("::1"), ("::1", 161));
}
#[test]
fn test_split_host_port_ipv6_bracketed_with_port() {
assert_eq!(split_host_port("[fe80::1]:162"), ("fe80::1", 162));
}
#[test]
fn test_split_host_port_ipv6_bracketed_default() {
assert_eq!(split_host_port("[::1]"), ("::1", 161));
}
#[test]
fn test_split_host_port_hostname() {
assert_eq!(split_host_port("switch.local"), ("switch.local", 161));
}
#[test]
fn test_split_host_port_hostname_with_port() {
assert_eq!(split_host_port("switch.local:162"), ("switch.local", 162));
}
#[test]
fn decoding_policy_defaults_strict_preset_and_targeted_override() {
let default = ClientBuilder::new(Auth::v2c("public")).build_config();
assert_eq!(default.decode_config, crate::DecodeConfig::DEFAULT);
let mut targeted = crate::DecodeConfig::STRICT;
targeted.empty_counter64_as_zero = true;
let configured = ClientBuilder::new(Auth::v2c("public"))
.decode_config(targeted)
.build_config();
assert_eq!(configured.decode_config, targeted);
}
}