use async_trait::async_trait;
use hickory_resolver::ResolveErrorKind;
use hickory_resolver::TokioResolver;
use hickory_resolver::config::{NameServerConfigGroup, ResolverConfig, ResolverOpts};
use hickory_resolver::name_server::TokioConnectionProvider;
use hickory_resolver::proto::ProtoErrorKind;
use hickory_resolver::proto::op::ResponseCode;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum DnsResolverConfig {
#[default]
System,
Cloudflare,
CloudflareTls,
Google,
GoogleTls,
Quad9,
Quad9Tls,
}
impl DnsResolverConfig {
pub(crate) fn to_resolver_config(self) -> Result<(ResolverConfig, ResolverOpts), DnsError> {
match self {
Self::System => hickory_resolver::system_conf::read_system_conf().map_err(|e| {
DnsError::LookupFailed {
fqdn: "(system config)".to_string(),
reason: format!("failed to read system DNS config: {e}"),
}
}),
Self::Cloudflare => Ok((ResolverConfig::cloudflare(), ResolverOpts::default())),
Self::CloudflareTls => Ok((ResolverConfig::cloudflare_tls(), ResolverOpts::default())),
Self::Google => Ok((ResolverConfig::google(), ResolverOpts::default())),
Self::GoogleTls => Ok((ResolverConfig::google_tls(), ResolverOpts::default())),
Self::Quad9 => Ok((ResolverConfig::quad9(), ResolverOpts::default())),
Self::Quad9Tls => Ok((ResolverConfig::quad9_tls(), ResolverOpts::default())),
}
}
}
use crate::dane::TlsaRecord;
use crate::error::{DaneError, DnsError};
use ans_types::{Fqdn, ParseError, Version};
#[derive(Debug, Clone)]
pub struct BadgeRecord {
pub(crate) format_version: String,
pub(crate) version: Option<Version>,
pub(crate) url: String,
}
impl BadgeRecord {
pub fn format_version(&self) -> &str {
&self.format_version
}
pub fn version(&self) -> Option<&Version> {
self.version.as_ref()
}
pub fn url(&self) -> &str {
&self.url
}
pub fn parse(txt: &str) -> Result<Self, ParseError> {
let mut format_version = None;
let mut version = None;
let mut url = None;
for part in txt.split(';') {
let part = part.trim();
if let Some(v) = part.strip_prefix("v=") {
format_version = Some(v.to_string());
} else if let Some(v) = part.strip_prefix("version=") {
version = Version::parse(v).ok();
} else if let Some(u) = part.strip_prefix("url=") {
url::Url::parse(u).map_err(|e| ParseError::InvalidUrl(e.to_string()))?;
url = Some(u.to_string());
}
}
let format_version =
format_version.ok_or_else(|| ParseError::MissingField("v".to_string()))?;
let url = url.ok_or_else(|| ParseError::MissingField("url".to_string()))?;
tracing::debug!(
format_version = %format_version,
version = ?version,
url = %url,
"Parsed badge TXT record"
);
Ok(Self {
format_version,
version,
url,
})
}
}
#[cfg(any(test, feature = "test-support"))]
impl BadgeRecord {
pub fn new(
format_version: impl Into<String>,
version: Option<Version>,
url: impl Into<String>,
) -> Self {
Self {
format_version: format_version.into(),
version,
url: url.into(),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum DnsLookupResult<T> {
Found(Vec<T>),
NotFound,
}
#[async_trait]
pub trait DnsResolver: Send + Sync {
async fn lookup_badge(&self, fqdn: &Fqdn) -> Result<DnsLookupResult<BadgeRecord>, DnsError>;
async fn lookup_tlsa(
&self,
fqdn: &Fqdn,
port: u16,
) -> Result<DnsLookupResult<TlsaRecord>, DnsError>;
async fn get_badge_records(&self, fqdn: &Fqdn) -> Result<Vec<BadgeRecord>, DnsError> {
match self.lookup_badge(fqdn).await? {
DnsLookupResult::Found(records) => Ok(records),
DnsLookupResult::NotFound => Err(DnsError::NotFound {
fqdn: fqdn.to_string(),
}),
}
}
async fn get_tlsa_records(&self, fqdn: &Fqdn, port: u16) -> Result<Vec<TlsaRecord>, DaneError> {
match self.lookup_tlsa(fqdn, port).await {
Ok(DnsLookupResult::Found(records)) => Ok(records),
Ok(DnsLookupResult::NotFound) => Ok(vec![]),
Err(e) => Err(DaneError::DnsError(e)),
}
}
async fn find_badge_for_version(
&self,
fqdn: &Fqdn,
version: &Version,
) -> Result<Option<BadgeRecord>, DnsError> {
let records = self.get_badge_records(fqdn).await?;
Ok(records.into_iter().find(|r| {
match &r.version {
Some(v) => v == version,
None => true, }
}))
}
async fn find_preferred_badge(&self, fqdn: &Fqdn) -> Result<Option<BadgeRecord>, DnsError> {
let mut records = self.get_badge_records(fqdn).await?;
if records.is_empty() {
return Ok(None);
}
records.sort_by(|a, b| match (&b.version, &a.version) {
(Some(vb), Some(va)) => vb.cmp(va),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
Ok(Some(records.remove(0)))
}
}
pub struct HickoryDnsResolver {
resolver: TokioResolver,
dnssec_resolver: TokioResolver,
}
impl fmt::Debug for HickoryDnsResolver {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HickoryDnsResolver").finish_non_exhaustive()
}
}
#[allow(clippy::unused_async)] impl HickoryDnsResolver {
pub async fn new() -> Result<Self, DnsError> {
Self::with_preset(DnsResolverConfig::System).await
}
pub async fn with_preset(preset: DnsResolverConfig) -> Result<Self, DnsError> {
let (config, opts) = preset.to_resolver_config()?;
let mut builder =
TokioResolver::builder_with_config(config.clone(), TokioConnectionProvider::default());
*builder.options_mut() = opts.clone();
let resolver = builder.build();
let mut dnssec_builder =
TokioResolver::builder_with_config(config, TokioConnectionProvider::default());
let dnssec_opts = dnssec_builder.options_mut();
*dnssec_opts = opts;
dnssec_opts.validate = true;
let dnssec_resolver = dnssec_builder.build();
tracing::debug!(preset = ?preset, "Created DNS resolver");
Ok(Self {
resolver,
dnssec_resolver,
})
}
pub async fn with_nameservers(nameservers: &[Ipv4Addr]) -> Result<Self, DnsError> {
let ips: Vec<IpAddr> = nameservers.iter().map(|ip| IpAddr::V4(*ip)).collect();
let config = ResolverConfig::from_parts(
None,
vec![],
NameServerConfigGroup::from_ips_clear(&ips, 53, true),
);
let resolver =
TokioResolver::builder_with_config(config.clone(), TokioConnectionProvider::default())
.build();
let mut dnssec_builder =
TokioResolver::builder_with_config(config, TokioConnectionProvider::default());
dnssec_builder.options_mut().validate = true;
let dnssec_resolver = dnssec_builder.build();
tracing::debug!(nameservers = ?nameservers, "Created DNS resolver with custom nameservers");
Ok(Self {
resolver,
dnssec_resolver,
})
}
pub async fn with_config(config: ResolverConfig, opts: ResolverOpts) -> Result<Self, DnsError> {
let mut builder =
TokioResolver::builder_with_config(config.clone(), TokioConnectionProvider::default());
*builder.options_mut() = opts.clone();
let resolver = builder.build();
let mut dnssec_builder =
TokioResolver::builder_with_config(config, TokioConnectionProvider::default());
let dnssec_opts = dnssec_builder.options_mut();
*dnssec_opts = opts;
dnssec_opts.validate = true;
let dnssec_resolver = dnssec_builder.build();
Ok(Self {
resolver,
dnssec_resolver,
})
}
pub async fn with_dnssec() -> Result<Self, DnsError> {
let mut builder = TokioResolver::builder_with_config(
ResolverConfig::default(),
TokioConnectionProvider::default(),
);
builder.options_mut().validate = true;
let resolver = builder.build();
let mut dnssec_builder = TokioResolver::builder_with_config(
ResolverConfig::default(),
TokioConnectionProvider::default(),
);
dnssec_builder.options_mut().validate = true;
let dnssec_resolver = dnssec_builder.build();
Ok(Self {
resolver,
dnssec_resolver,
})
}
}
impl HickoryDnsResolver {
async fn query_badge_txt(
&self,
query_name: &str,
fqdn: &Fqdn,
) -> Result<DnsLookupResult<BadgeRecord>, DnsError> {
let response = match self.resolver.txt_lookup(query_name).await {
Ok(response) => response,
Err(e) => match e.kind() {
ResolveErrorKind::Proto(proto_err) => match proto_err.kind() {
ProtoErrorKind::NoRecordsFound { .. } => {
return Ok(DnsLookupResult::NotFound);
}
ProtoErrorKind::Timeout => {
return Err(DnsError::Timeout {
fqdn: fqdn.to_string(),
});
}
_ => {
return Err(DnsError::LookupFailed {
fqdn: fqdn.to_string(),
reason: e.to_string(),
});
}
},
_ => {
return Err(DnsError::LookupFailed {
fqdn: fqdn.to_string(),
reason: e.to_string(),
});
}
},
};
let mut records = Vec::new();
for txt in response.iter() {
let txt_data: String = txt
.txt_data()
.iter()
.map(|d| String::from_utf8_lossy(d).to_string())
.collect::<String>();
match BadgeRecord::parse(&txt_data) {
Ok(record) => records.push(record),
Err(_) => {
tracing::warn!(
fqdn = %fqdn,
record = %txt_data,
"Skipping malformed badge TXT record"
);
}
}
}
if records.is_empty() {
Ok(DnsLookupResult::NotFound)
} else {
Ok(DnsLookupResult::Found(records))
}
}
}
#[allow(clippy::too_many_lines)] #[async_trait]
impl DnsResolver for HickoryDnsResolver {
async fn lookup_badge(&self, fqdn: &Fqdn) -> Result<DnsLookupResult<BadgeRecord>, DnsError> {
let primary = fqdn.ans_badge_name();
tracing::debug!(query = %primary, "Querying primary _ans-badge record");
match self.query_badge_txt(&primary, fqdn).await? {
DnsLookupResult::Found(records) => return Ok(DnsLookupResult::Found(records)),
DnsLookupResult::NotFound => {
let fallback = fqdn.ra_badge_name();
tracing::debug!(query = %fallback, "Primary not found, falling back to _ra-badge");
self.query_badge_txt(&fallback, fqdn).await
}
}
}
async fn lookup_tlsa(
&self,
fqdn: &Fqdn,
port: u16,
) -> Result<DnsLookupResult<TlsaRecord>, DnsError> {
let query_name = fqdn.tlsa_name(port);
tracing::debug!(
query = %query_name,
"Performing DNSSEC-validated TLSA lookup"
);
let response = match self.dnssec_resolver.tlsa_lookup(&query_name).await {
Ok(response) => response,
Err(e) => {
match e.kind() {
ResolveErrorKind::Proto(proto_err) => match proto_err.kind() {
ProtoErrorKind::NoRecordsFound { response_code, .. } => {
if *response_code == ResponseCode::ServFail {
tracing::error!(
fqdn = %fqdn,
"TLSA lookup returned ServFail — possible DNSSEC failure"
);
return Err(DnsError::DnssecFailed {
fqdn: fqdn.to_string(),
});
}
return Ok(DnsLookupResult::NotFound);
}
ProtoErrorKind::Timeout => {
return Err(DnsError::Timeout {
fqdn: fqdn.to_string(),
});
}
ProtoErrorKind::Nsec { .. } => {
tracing::error!(
fqdn = %fqdn,
error = %e,
"DNSSEC validation failed for TLSA record (NSEC proof)"
);
return Err(DnsError::DnssecFailed {
fqdn: fqdn.to_string(),
});
}
_ => {
let err_str = proto_err.to_string();
if matches_dnssec_pattern(&err_str) {
tracing::error!(
fqdn = %fqdn,
error = %e,
"DNSSEC validation failed for TLSA record"
);
return Err(DnsError::DnssecFailed {
fqdn: fqdn.to_string(),
});
}
tracing::warn!(
fqdn = %fqdn,
error = %proto_err,
"Proto error did not match DNSSEC patterns, classifying as LookupFailed"
);
return Err(DnsError::LookupFailed {
fqdn: fqdn.to_string(),
reason: e.to_string(),
});
}
},
_ => {
return Err(DnsError::LookupFailed {
fqdn: fqdn.to_string(),
reason: e.to_string(),
});
}
}
}
};
tracing::debug!(
fqdn = %fqdn,
port,
"TLSA lookup succeeded (DNSSEC validated if domain has DNSSEC)"
);
let mut records = Vec::new();
for tlsa in response.iter() {
let mut rdata = vec![
tlsa.cert_usage().into(),
tlsa.selector().into(),
tlsa.matching().into(),
];
rdata.extend(tlsa.cert_data());
match TlsaRecord::from_rdata(&rdata) {
Ok(record) => {
tracing::debug!(
fqdn = %fqdn,
port,
usage = ?record.usage,
selector = ?record.selector,
matching_type = ?record.matching_type,
"Parsed TLSA record"
);
records.push(record);
}
Err(e) => {
tracing::warn!(
fqdn = %fqdn,
port,
error = %e,
"Skipping malformed TLSA record"
);
}
}
}
if records.is_empty() {
Ok(DnsLookupResult::NotFound)
} else {
Ok(DnsLookupResult::Found(records))
}
}
}
fn matches_dnssec_pattern(err_str: &str) -> bool {
err_str.contains("DNSSEC") || err_str.contains("validation")
}
#[cfg(any(test, feature = "test-support"))]
#[derive(Debug, Default, Clone)]
pub struct MockDnsResolver {
records: std::collections::HashMap<String, Vec<BadgeRecord>>,
tlsa_records: std::collections::HashMap<String, Vec<TlsaRecord>>,
errors: std::collections::HashMap<String, DnsError>,
tlsa_errors: std::collections::HashMap<String, DnsError>,
}
#[cfg(any(test, feature = "test-support"))]
impl MockDnsResolver {
pub fn new() -> Self {
Self::default()
}
pub fn with_records(mut self, fqdn: &str, records: Vec<BadgeRecord>) -> Self {
self.records.insert(fqdn.to_lowercase(), records);
self
}
pub fn with_tlsa_records(mut self, fqdn: &str, port: u16, records: Vec<TlsaRecord>) -> Self {
let key = format!("{}:{}", fqdn.to_lowercase(), port);
self.tlsa_records.insert(key, records);
self
}
pub fn with_error(mut self, fqdn: &str, error: DnsError) -> Self {
self.errors.insert(fqdn.to_lowercase(), error);
self
}
pub fn with_tlsa_error(mut self, fqdn: &str, port: u16, error: DnsError) -> Self {
let key = format!("{}:{}", fqdn.to_lowercase(), port);
self.tlsa_errors.insert(key, error);
self
}
}
#[cfg(any(test, feature = "test-support"))]
#[async_trait]
impl DnsResolver for MockDnsResolver {
async fn lookup_badge(&self, fqdn: &Fqdn) -> Result<DnsLookupResult<BadgeRecord>, DnsError> {
let key = fqdn.as_str().to_lowercase();
if let Some(error) = self.errors.get(&key) {
return Err(error.clone());
}
match self.records.get(&key) {
Some(records) if !records.is_empty() => Ok(DnsLookupResult::Found(records.clone())),
_ => Ok(DnsLookupResult::NotFound),
}
}
async fn lookup_tlsa(
&self,
fqdn: &Fqdn,
port: u16,
) -> Result<DnsLookupResult<TlsaRecord>, DnsError> {
let key = format!("{}:{}", fqdn.as_str().to_lowercase(), port);
if let Some(error) = self.tlsa_errors.get(&key) {
return Err(error.clone());
}
if let Some(error) = self.errors.get(&fqdn.as_str().to_lowercase()) {
return Err(error.clone());
}
match self.tlsa_records.get(&key) {
Some(records) if !records.is_empty() => Ok(DnsLookupResult::Found(records.clone())),
_ => Ok(DnsLookupResult::NotFound),
}
}
}
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_badge_record_with_version() {
let txt = "v=ans-badge1; version=v1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/7b93c61c-e261-488c-89a3-f948119be0a0";
let record = BadgeRecord::parse(txt).unwrap();
assert_eq!(record.format_version, "ans-badge1");
assert_eq!(record.version, Some(Version::new(1, 0, 0)));
assert_eq!(
record.url,
"https://transparency.ans.godaddy.com/v1/agents/7b93c61c-e261-488c-89a3-f948119be0a0"
);
}
#[test]
fn test_parse_badge_record_without_version() {
let txt = "v=ans-badge1; url=https://transparency.ans.ote-godaddy.com/v1/agents/835a27a8-6b20-4439-915e-668a9d36e469";
let record = BadgeRecord::parse(txt).unwrap();
assert_eq!(record.format_version, "ans-badge1");
assert_eq!(record.version, None);
assert_eq!(
record.url,
"https://transparency.ans.ote-godaddy.com/v1/agents/835a27a8-6b20-4439-915e-668a9d36e469"
);
}
#[test]
fn test_parse_badge_record_missing_url() {
let txt = "v=ans-badge1; version=v1.0.0";
assert!(BadgeRecord::parse(txt).is_err());
}
#[test]
fn test_parse_badge_record_no_space_after_semicolon() {
let txt = "v=ans-badge1;version=v1.0.0;url=https://transparency.ans.godaddy.com/v1/agents/7b93c61c-e261-488c-89a3-f948119be0a0";
let record = BadgeRecord::parse(txt).unwrap();
assert_eq!(record.format_version, "ans-badge1");
assert_eq!(record.version, Some(Version::new(1, 0, 0)));
assert_eq!(
record.url,
"https://transparency.ans.godaddy.com/v1/agents/7b93c61c-e261-488c-89a3-f948119be0a0"
);
}
#[test]
fn test_parse_legacy_ra_badge_format() {
let txt = "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/test-id";
let record = BadgeRecord::parse(txt).unwrap();
assert_eq!(record.format_version, "ra-badge1");
assert_eq!(record.version, Some(Version::new(1, 0, 0)));
}
#[tokio::test]
async fn test_mock_resolver_found() {
let record = BadgeRecord {
format_version: "ans-badge1".to_string(),
version: Some(Version::new(1, 0, 0)),
url: "https://example.com/badge".to_string(),
};
let resolver =
MockDnsResolver::new().with_records("agent.example.com", vec![record.clone()]);
let fqdn = Fqdn::new("agent.example.com").unwrap();
let result = resolver.lookup_badge(&fqdn).await.unwrap();
match result {
DnsLookupResult::Found(records) => {
assert_eq!(records.len(), 1);
assert_eq!(records[0].version, Some(Version::new(1, 0, 0)));
}
DnsLookupResult::NotFound => panic!("Expected Found"),
}
}
#[tokio::test]
async fn test_mock_resolver_not_found() {
let resolver = MockDnsResolver::new();
let fqdn = Fqdn::new("unknown.example.com").unwrap();
let result = resolver.lookup_badge(&fqdn).await.unwrap();
assert!(matches!(result, DnsLookupResult::NotFound));
}
#[tokio::test]
async fn test_mock_resolver_error() {
let resolver = MockDnsResolver::new().with_error(
"error.example.com",
DnsError::Timeout {
fqdn: "error.example.com".to_string(),
},
);
let fqdn = Fqdn::new("error.example.com").unwrap();
let result = resolver.lookup_badge(&fqdn).await;
assert!(matches!(result, Err(DnsError::Timeout { .. })));
}
#[tokio::test]
async fn test_find_badge_for_version() {
let v1 = BadgeRecord {
format_version: "ans-badge1".to_string(),
version: Some(Version::new(1, 0, 0)),
url: "https://example.com/v1".to_string(),
};
let v2 = BadgeRecord {
format_version: "ans-badge1".to_string(),
version: Some(Version::new(1, 0, 1)),
url: "https://example.com/v2".to_string(),
};
let resolver = MockDnsResolver::new().with_records("agent.example.com", vec![v1, v2]);
let fqdn = Fqdn::new("agent.example.com").unwrap();
let found = resolver
.find_badge_for_version(&fqdn, &Version::new(1, 0, 0))
.await
.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().version, Some(Version::new(1, 0, 0)));
let found = resolver
.find_badge_for_version(&fqdn, &Version::new(1, 0, 1))
.await
.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().version, Some(Version::new(1, 0, 1)));
let found = resolver
.find_badge_for_version(&fqdn, &Version::new(2, 0, 0))
.await
.unwrap();
assert!(found.is_none());
}
#[test]
fn test_dnssec_pattern_matches_uppercase_dnssec() {
assert!(matches_dnssec_pattern("DNSSEC validation failed"));
assert!(matches_dnssec_pattern("DNSSEC error: bogus response"));
assert!(matches_dnssec_pattern("proto error: DNSSEC"));
}
#[test]
fn test_dnssec_pattern_matches_validation_keyword() {
assert!(matches_dnssec_pattern("validation failed for record"));
assert!(matches_dnssec_pattern("RRSIG validation error"));
assert!(matches_dnssec_pattern("chain of trust validation failure"));
}
#[test]
fn test_dnssec_pattern_known_hickory_messages() {
let known_messages = [
"DNSSEC validation failed",
"DNSSEC error",
"validation of DNSKEY failed",
"no DNSKEY proof for DS: validation failed",
];
for msg in &known_messages {
assert!(
matches_dnssec_pattern(msg),
"Expected DNSSEC pattern match for known hickory message: {msg:?}"
);
}
}
#[test]
fn test_dnssec_pattern_does_not_match_generic_errors() {
assert!(!matches_dnssec_pattern("connection refused"));
assert!(!matches_dnssec_pattern("timeout"));
assert!(!matches_dnssec_pattern("no records found"));
assert!(!matches_dnssec_pattern("io error: broken pipe"));
assert!(!matches_dnssec_pattern("proto error: invalid message"));
}
#[tokio::test]
async fn test_mock_tlsa_dnssec_error() {
let resolver = MockDnsResolver::new().with_tlsa_error(
"secure.example.com",
443,
DnsError::DnssecFailed {
fqdn: "secure.example.com".to_string(),
},
);
let fqdn = Fqdn::new("secure.example.com").unwrap();
let result = resolver.lookup_tlsa(&fqdn, 443).await;
assert!(matches!(result, Err(DnsError::DnssecFailed { .. })));
}
#[tokio::test]
#[ignore = "requires network access — run with: cargo test -p ans-verify -- --ignored"]
async fn test_real_dnssec_chain_validation_fails() {
use hickory_resolver::TokioResolver;
use hickory_resolver::config::LookupIpStrategy;
use hickory_resolver::name_server::TokioConnectionProvider;
let mut builder = TokioResolver::builder_with_config(
hickory_resolver::config::ResolverConfig::default(),
TokioConnectionProvider::default(),
);
let opts = builder.options_mut();
opts.validate = true;
opts.ip_strategy = LookupIpStrategy::Ipv4Only;
let resolver = builder.build();
let result = resolver.lookup_ip("dnssec-failed.org.").await;
let err = result.expect_err("dnssec-failed.org must not resolve — DNSSEC chain is broken");
match err.kind() {
ResolveErrorKind::Proto(proto_err) => match proto_err.kind() {
ProtoErrorKind::NoRecordsFound { response_code, .. }
if *response_code == ResponseCode::ServFail => {}
ProtoErrorKind::Nsec { .. } => {}
_ => {
let err_str = proto_err.to_string();
assert!(
matches_dnssec_pattern(&err_str),
"Proto error from dnssec-failed.org did not match DNSSEC \
detection patterns. Hickory may have changed error format. \
Error: {err_str}"
);
}
},
other => {
panic!(
"Expected Proto DNSSEC error for dnssec-failed.org, \
got: {other:?}"
);
}
}
}
#[tokio::test]
async fn test_mock_tlsa_error_independent_of_badge() {
let record = BadgeRecord {
format_version: "ans-badge1".to_string(),
version: Some(Version::new(1, 0, 0)),
url: "https://example.com/badge".to_string(),
};
let resolver = MockDnsResolver::new()
.with_records("agent.example.com", vec![record])
.with_tlsa_error(
"agent.example.com",
443,
DnsError::DnssecFailed {
fqdn: "agent.example.com".to_string(),
},
);
let fqdn = Fqdn::new("agent.example.com").unwrap();
let badge_result = resolver.lookup_badge(&fqdn).await;
assert!(badge_result.is_ok());
let tlsa_result = resolver.lookup_tlsa(&fqdn, 443).await;
assert!(matches!(tlsa_result, Err(DnsError::DnssecFailed { .. })));
}
#[test]
fn test_dns_resolver_config_default() {
assert_eq!(DnsResolverConfig::default(), DnsResolverConfig::System);
}
#[test]
fn test_cloudflare_preset() {
let (config, _) = DnsResolverConfig::Cloudflare.to_resolver_config().unwrap();
assert!(!config.name_servers().is_empty());
}
#[test]
fn test_cloudflare_tls_preset() {
let (config, _) = DnsResolverConfig::CloudflareTls
.to_resolver_config()
.unwrap();
assert!(!config.name_servers().is_empty());
}
#[test]
fn test_google_preset() {
let (config, _) = DnsResolverConfig::Google.to_resolver_config().unwrap();
assert!(!config.name_servers().is_empty());
}
#[test]
fn test_google_tls_preset() {
let (config, _) = DnsResolverConfig::GoogleTls.to_resolver_config().unwrap();
assert!(!config.name_servers().is_empty());
}
#[test]
fn test_quad9_preset() {
let (config, _) = DnsResolverConfig::Quad9.to_resolver_config().unwrap();
assert!(!config.name_servers().is_empty());
}
#[test]
fn test_quad9_tls_preset() {
let (config, _) = DnsResolverConfig::Quad9Tls.to_resolver_config().unwrap();
assert!(!config.name_servers().is_empty());
}
#[tokio::test]
async fn test_hickory_with_preset_cloudflare() {
let resolver = HickoryDnsResolver::with_preset(DnsResolverConfig::Cloudflare).await;
assert!(resolver.is_ok());
}
#[tokio::test]
async fn test_hickory_with_preset_google() {
let resolver = HickoryDnsResolver::with_preset(DnsResolverConfig::Google).await;
assert!(resolver.is_ok());
}
#[tokio::test]
async fn test_hickory_with_preset_quad9() {
let resolver = HickoryDnsResolver::with_preset(DnsResolverConfig::Quad9).await;
assert!(resolver.is_ok());
}
#[tokio::test]
async fn test_hickory_with_nameservers() {
let resolver = HickoryDnsResolver::with_nameservers(&[
Ipv4Addr::new(1, 1, 1, 1),
Ipv4Addr::new(8, 8, 8, 8),
])
.await;
assert!(resolver.is_ok());
}
#[tokio::test]
async fn test_hickory_with_config() {
let resolver =
HickoryDnsResolver::with_config(ResolverConfig::cloudflare(), ResolverOpts::default())
.await;
assert!(resolver.is_ok());
}
#[tokio::test]
async fn test_hickory_with_dnssec() {
let resolver = HickoryDnsResolver::with_dnssec().await;
assert!(resolver.is_ok());
}
#[tokio::test]
async fn test_hickory_debug_format() {
let resolver = HickoryDnsResolver::with_preset(DnsResolverConfig::Cloudflare)
.await
.unwrap();
let dbg = format!("{resolver:?}");
assert!(dbg.contains("HickoryDnsResolver"));
}
#[tokio::test]
async fn test_get_badge_records_found() {
let record = BadgeRecord::new(
"ans-badge1",
Some(Version::new(1, 0, 0)),
"https://example.com/badge",
);
let resolver = MockDnsResolver::new().with_records("agent.example.com", vec![record]);
let fqdn = Fqdn::new("agent.example.com").unwrap();
let records = resolver.get_badge_records(&fqdn).await.unwrap();
assert_eq!(records.len(), 1);
}
#[tokio::test]
async fn test_get_badge_records_not_found() {
let resolver = MockDnsResolver::new();
let fqdn = Fqdn::new("unknown.example.com").unwrap();
let result = resolver.get_badge_records(&fqdn).await;
assert!(matches!(result, Err(DnsError::NotFound { .. })));
}
#[tokio::test]
async fn test_get_tlsa_records_found() {
let tlsa = crate::dane::TlsaRecord::new(
crate::dane::TlsaUsage::DomainIssuedCertificate,
crate::dane::TlsaSelector::FullCertificate,
crate::dane::TlsaMatchingType::Sha256,
vec![0; 32],
);
let resolver =
MockDnsResolver::new().with_tlsa_records("agent.example.com", 443, vec![tlsa]);
let fqdn = Fqdn::new("agent.example.com").unwrap();
let records = resolver.get_tlsa_records(&fqdn, 443).await.unwrap();
assert_eq!(records.len(), 1);
}
#[tokio::test]
async fn test_get_tlsa_records_not_found() {
let resolver = MockDnsResolver::new();
let fqdn = Fqdn::new("unknown.example.com").unwrap();
let records = resolver.get_tlsa_records(&fqdn, 443).await.unwrap();
assert!(records.is_empty());
}
#[tokio::test]
async fn test_get_tlsa_records_error_propagation() {
let resolver = MockDnsResolver::new().with_tlsa_error(
"agent.example.com",
443,
DnsError::DnssecFailed {
fqdn: "agent.example.com".to_string(),
},
);
let fqdn = Fqdn::new("agent.example.com").unwrap();
let result = resolver.get_tlsa_records(&fqdn, 443).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_find_preferred_badge_newest_first() {
let v1 = BadgeRecord::new(
"ans-badge1",
Some(Version::new(1, 0, 0)),
"https://example.com/v1",
);
let v2 = BadgeRecord::new(
"ans-badge1",
Some(Version::new(2, 0, 0)),
"https://example.com/v2",
);
let resolver = MockDnsResolver::new().with_records("agent.example.com", vec![v1, v2]);
let fqdn = Fqdn::new("agent.example.com").unwrap();
let preferred = resolver.find_preferred_badge(&fqdn).await.unwrap().unwrap();
assert_eq!(preferred.version(), Some(&Version::new(2, 0, 0)));
}
#[tokio::test]
async fn test_find_preferred_badge_none_version_sorting() {
let versioned = BadgeRecord::new(
"ans-badge1",
Some(Version::new(1, 0, 0)),
"https://example.com/v1",
);
let unversioned = BadgeRecord::new("ans-badge1", None, "https://example.com/unversioned");
let resolver =
MockDnsResolver::new().with_records("agent.example.com", vec![versioned, unversioned]);
let fqdn = Fqdn::new("agent.example.com").unwrap();
let preferred = resolver.find_preferred_badge(&fqdn).await.unwrap().unwrap();
assert_eq!(preferred.version(), None);
}
#[tokio::test]
async fn test_find_preferred_badge_empty() {
let resolver = MockDnsResolver::new();
let fqdn = Fqdn::new("unknown.example.com").unwrap();
let result = resolver.find_preferred_badge(&fqdn).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_find_badge_for_version_none_matches_any() {
let unversioned = BadgeRecord::new("ans-badge1", None, "https://example.com/badge");
let resolver = MockDnsResolver::new().with_records("agent.example.com", vec![unversioned]);
let fqdn = Fqdn::new("agent.example.com").unwrap();
let found = resolver
.find_badge_for_version(&fqdn, &Version::new(99, 0, 0))
.await
.unwrap();
assert!(found.is_some());
}
#[test]
fn test_badge_record_accessors() {
let record = BadgeRecord::new(
"ans-badge1",
Some(Version::new(1, 2, 3)),
"https://example.com/badge",
);
assert_eq!(record.format_version(), "ans-badge1");
assert_eq!(record.version(), Some(&Version::new(1, 2, 3)));
assert_eq!(record.url(), "https://example.com/badge");
}
#[test]
fn test_badge_record_no_version() {
let record = BadgeRecord::new("ra-badge1", None, "https://example.com/badge");
assert_eq!(record.version(), None);
}
}