use std::io;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::time::Duration;
use hickory_proto::dnssec::Proof;
use hickory_proto::op::ResponseCode;
use hickory_resolver::config::{
ConnectionConfig, NameServerConfig, ProtocolConfig, ResolveHosts, ResolverConfig, ResolverOpts,
};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::Resolver;
use crate::resolver_refresh::dnssec::TrustAnchors;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedAnswer {
pub targets: Vec<String>,
pub ttl_seconds: u32,
pub resolver_addr: SocketAddr,
}
fn build_nameserver_config(upstream: SocketAddr) -> NameServerConfig {
let mut udp = ConnectionConfig::new(ProtocolConfig::Udp);
udp.port = upstream.port();
let mut tcp = ConnectionConfig::new(ProtocolConfig::Tcp);
tcp.port = upstream.port();
NameServerConfig::new(upstream.ip(), true, vec![udp, tcp])
}
pub(crate) fn build_resolver_opts(
timeout: Duration,
trust_anchor: Option<PathBuf>,
) -> ResolverOpts {
let mut opts = ResolverOpts::default();
opts.cache_size = 0;
opts.attempts = 1;
opts.timeout = timeout;
opts.use_hosts_file = ResolveHosts::Never;
opts.edns0 = false;
opts.ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
if let Some(path) = trust_anchor {
opts.trust_anchor = Some(path);
}
opts
}
pub async fn resolve_with_ttl(
hostname: &str,
upstream: SocketAddr,
timeout: Duration,
) -> io::Result<ResolvedAnswer> {
let mut config = ResolverConfig::from_parts(None, Vec::new(), Vec::new());
config.add_name_server(build_nameserver_config(upstream));
let opts = build_resolver_opts(timeout, None);
let resolver = Resolver::builder_with_config(config, TokioRuntimeProvider::default())
.with_options(opts)
.build()
.map_err(|e| io::Error::other(format!("hickory-resolver build: {e}")))?;
let work = async {
let lookup_result = resolver.lookup_ip(hostname).await;
let lookup = match lookup_result {
Ok(l) => l,
Err(e) => {
if let Some(rc) = no_records_response_code(&e) {
if matches!(rc, ResponseCode::NXDomain | ResponseCode::NoError) {
return Ok(ResolvedAnswer {
targets: Vec::new(),
ttl_seconds: 0,
resolver_addr: upstream,
});
}
}
return Err(map_net_error(e));
}
};
let (targets, min_ttl) = collect_targets_and_min_ttl(lookup.as_lookup().answers());
Ok(ResolvedAnswer {
targets,
ttl_seconds: min_ttl.unwrap_or(0),
resolver_addr: upstream,
})
};
match tokio::time::timeout(timeout, work).await {
Ok(inner) => inner,
Err(_) => Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("hickory-resolver timed out after {timeout:?} for {hostname}"),
)),
}
}
fn collect_targets_and_min_ttl(
records: &[hickory_resolver::proto::rr::Record],
) -> (Vec<String>, Option<u32>) {
let mut min_ttl: Option<u32> = None;
let mut targets: Vec<String> = Vec::new();
for record in records {
let ttl = record.ttl;
min_ttl = Some(match min_ttl {
Some(prev) => prev.min(ttl),
None => ttl,
});
if let Some(ip) = record_to_ip(record) {
targets.push(ip.to_string());
}
}
targets.sort();
targets.dedup();
(targets, min_ttl)
}
fn record_to_ip(record: &hickory_resolver::proto::rr::Record) -> Option<IpAddr> {
use hickory_resolver::proto::rr::RData;
match &record.data {
RData::A(a) => Some(IpAddr::V4(a.0)),
RData::AAAA(aaaa) => Some(IpAddr::V6(aaaa.0)),
_ => None,
}
}
fn no_records_response_code(e: &NetError) -> Option<ResponseCode> {
match e {
NetError::Dns(DnsError::NoRecordsFound(no_records)) => Some(no_records.response_code),
_ => None,
}
}
fn map_net_error(e: NetError) -> io::Error {
let kind = match &e {
NetError::Timeout => io::ErrorKind::TimedOut,
NetError::Io(io_err) => io_err.kind(),
_ => io::ErrorKind::Other,
};
io::Error::new(kind, format!("hickory-resolver error: {e}"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DnssecValidationResult {
Validated {
algorithm: String,
key_tag: u16,
},
Failed {
reason: String,
},
Unsigned,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedResolvedAnswer {
pub answer: ResolvedAnswer,
pub validation: DnssecValidationResult,
}
pub(crate) fn proof_to_validation_result(proof: Proof) -> DnssecValidationResult {
proof_to_validation_result_with_rrsig(proof, None)
}
pub fn proof_to_validation_result_with_rrsig(
proof: Proof,
rrsig_metadata: Option<(String, u16)>,
) -> DnssecValidationResult {
match proof {
Proof::Secure => {
let (algorithm, key_tag) =
rrsig_metadata.unwrap_or_else(|| ("unknown".to_string(), 0u16));
DnssecValidationResult::Validated { algorithm, key_tag }
}
Proof::Insecure => DnssecValidationResult::Unsigned,
Proof::Bogus => DnssecValidationResult::Failed {
reason: "validation_failed".to_string(),
},
Proof::Indeterminate => DnssecValidationResult::Failed {
reason: "validation_indeterminate".to_string(),
},
}
}
pub fn extract_rrsig_metadata(
records: &[hickory_resolver::proto::rr::Record],
acceptable_types: &[hickory_resolver::proto::rr::RecordType],
) -> Option<(String, u16)> {
use hickory_resolver::proto::rr::RData;
for record in records {
if let RData::DNSSEC(hickory_proto::dnssec::rdata::DNSSECRData::RRSIG(rrsig)) = &record.data
{
let input = rrsig.input();
if acceptable_types.contains(&input.type_covered) {
return Some((input.algorithm.as_str().to_string(), input.key_tag));
}
}
}
None
}
fn worst_proof(records: &[hickory_resolver::proto::rr::Record]) -> Proof {
let mut have_any = false;
let mut all_secure = true;
let mut any_insecure = false;
let mut any_indeterminate = false;
for record in records {
have_any = true;
match record.proof {
Proof::Bogus => return Proof::Bogus,
Proof::Indeterminate => {
any_indeterminate = true;
all_secure = false;
}
Proof::Insecure => {
any_insecure = true;
all_secure = false;
}
Proof::Secure => {}
}
}
if !have_any {
return Proof::Indeterminate;
}
if all_secure {
Proof::Secure
} else if any_indeterminate {
Proof::Indeterminate
} else if any_insecure {
Proof::Insecure
} else {
Proof::Indeterminate
}
}
pub async fn resolve_with_ttl_validated(
hostname: &str,
upstream: SocketAddr,
timeout: Duration,
trust_anchors: &TrustAnchors,
) -> io::Result<ValidatedResolvedAnswer> {
let mut config = ResolverConfig::from_parts(None, Vec::new(), Vec::new());
config.add_name_server(build_nameserver_config(upstream));
let mut opts = build_resolver_opts(timeout, trust_anchors.path().map(PathBuf::from));
opts.validate = true;
let mut builder =
Resolver::builder_with_config(config, TokioRuntimeProvider::default()).with_options(opts);
if let Some(path) = trust_anchors.path() {
match hickory_proto::dnssec::TrustAnchors::from_file(path) {
Ok(anchors) => {
builder = builder.with_trust_anchor(std::sync::Arc::new(anchors));
}
Err(e) => {
return Err(io::Error::other(format!(
"trust anchor parse failed for {}: {e}",
path.display()
)));
}
}
}
let resolver = builder
.build()
.map_err(|e| io::Error::other(format!("hickory-resolver build (validating): {e}")))?;
let work = async {
let lookup_result = resolver.lookup_ip(hostname).await;
let lookup = match lookup_result {
Ok(l) => l,
Err(e) => {
if let Some(rc) = no_records_response_code(&e) {
if matches!(rc, ResponseCode::NXDomain | ResponseCode::NoError) {
let validation = nsec_proof(&e).map(proof_to_validation_result).unwrap_or(
DnssecValidationResult::Validated {
algorithm: "unknown".to_string(),
key_tag: 0,
},
);
return Ok(ValidatedResolvedAnswer {
answer: ResolvedAnswer {
targets: Vec::new(),
ttl_seconds: 0,
resolver_addr: upstream,
},
validation,
});
}
}
if let Some(proof) = nsec_proof(&e) {
return Ok(ValidatedResolvedAnswer {
answer: ResolvedAnswer {
targets: Vec::new(),
ttl_seconds: 0,
resolver_addr: upstream,
},
validation: proof_to_validation_result(proof),
});
}
return Err(map_net_error(e));
}
};
let answers = lookup.as_lookup().answers();
let (targets, min_ttl) = collect_targets_and_min_ttl(answers);
let proof = worst_proof(answers);
let rrsig_metadata = extract_rrsig_metadata(
answers,
&[
hickory_resolver::proto::rr::RecordType::A,
hickory_resolver::proto::rr::RecordType::AAAA,
],
);
Ok(ValidatedResolvedAnswer {
answer: ResolvedAnswer {
targets,
ttl_seconds: min_ttl.unwrap_or(0),
resolver_addr: upstream,
},
validation: proof_to_validation_result_with_rrsig(proof, rrsig_metadata),
})
};
match tokio::time::timeout(timeout, work).await {
Ok(inner) => inner,
Err(_) => Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("hickory-resolver timed out after {timeout:?} for {hostname} (DNSSEC path)"),
)),
}
}
fn nsec_proof(e: &NetError) -> Option<Proof> {
match e {
NetError::Dns(DnsError::Nsec { proof, .. }) => Some(*proof),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::oneshot;
#[derive(Clone)]
struct StubResponse {
rcode: u8,
a_records: Vec<(Ipv4Addr, u32)>,
swallow: bool,
}
impl StubResponse {
fn ok_a(records: Vec<(Ipv4Addr, u32)>) -> Self {
Self {
rcode: 0,
a_records: records,
swallow: false,
}
}
fn empty_noerror() -> Self {
Self {
rcode: 0,
a_records: Vec::new(),
swallow: false,
}
}
fn nxdomain() -> Self {
Self {
rcode: 3,
a_records: Vec::new(),
swallow: false,
}
}
fn servfail() -> Self {
Self {
rcode: 2,
a_records: Vec::new(),
swallow: false,
}
}
fn timeout() -> Self {
Self {
rcode: 0,
a_records: Vec::new(),
swallow: true,
}
}
}
fn build_response(query: &[u8], spec: &StubResponse) -> Vec<u8> {
let mut resp = query.to_vec();
resp[2] = 0x81;
resp[3] = 0x80 | (spec.rcode & 0x0f);
let ancount = if spec.rcode == 0 {
spec.a_records.len() as u16
} else {
0
};
resp[6] = (ancount >> 8) as u8;
resp[7] = (ancount & 0xff) as u8;
if spec.rcode == 0 {
let mut qend = 12;
while qend < query.len() && query[qend] != 0 {
let len = query[qend] as usize;
qend += 1 + len;
}
qend += 1;
let qname = &query[12..qend];
for (ip, ttl) in &spec.a_records {
resp.extend_from_slice(qname);
resp.extend_from_slice(&[0x00, 0x01]); resp.extend_from_slice(&[0x00, 0x01]); resp.extend_from_slice(&ttl.to_be_bytes()); resp.extend_from_slice(&[0x00, 0x04]); resp.extend_from_slice(&ip.octets());
}
}
resp
}
async fn spawn_stub(responses: Vec<StubResponse>) -> (SocketAddr, oneshot::Sender<()>) {
let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let addr = sock.local_addr().unwrap();
let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
let responses = Arc::new(responses);
tokio::spawn(async move {
let mut buf = vec![0u8; 1500];
let mut idx_a: usize = 0;
loop {
tokio::select! {
_ = &mut stop_rx => break,
r = sock.recv_from(&mut buf) => {
let (n, peer) = match r { Ok(v) => v, Err(_) => break };
let query = buf[..n].to_vec();
let mut idx = 12;
while idx < query.len() && query[idx] != 0 {
let len = query[idx] as usize;
idx += 1 + len;
}
idx += 1;
if idx + 4 > query.len() { continue; }
let qtype = u16::from_be_bytes([query[idx], query[idx+1]]);
if qtype == 1 {
let spec = if idx_a < responses.len() {
let s = responses[idx_a].clone();
idx_a += 1;
s
} else {
responses.last().cloned()
.unwrap_or_else(StubResponse::empty_noerror)
};
if !spec.swallow {
let resp = build_response(&query, &spec);
let _ = sock.send_to(&resp, peer).await;
}
} else {
let spec = StubResponse::empty_noerror();
let resp = build_response(&query, &spec);
let _ = sock.send_to(&resp, peer).await;
}
}
}
}
});
(addr, stop_tx)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_returns_ttl_from_a_records() {
let (addr, stop) = spawn_stub(vec![StubResponse::ok_a(vec![(
Ipv4Addr::new(203, 0, 113, 5),
300,
)])])
.await;
let answer = resolve_with_ttl("api.example.com.", addr, Duration::from_secs(2))
.await
.expect("resolve ok");
assert_eq!(answer.targets, vec!["203.0.113.5".to_string()]);
assert_eq!(answer.ttl_seconds, 300, "TTL must be the upstream record's");
assert_eq!(answer.resolver_addr, addr);
let _ = stop.send(());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_returns_min_ttl_across_a_aaaa() {
let (addr, stop) = spawn_stub(vec![StubResponse::ok_a(vec![
(Ipv4Addr::new(203, 0, 113, 1), 600),
(Ipv4Addr::new(203, 0, 113, 2), 120),
])])
.await;
let answer = resolve_with_ttl("multi.example.com.", addr, Duration::from_secs(2))
.await
.expect("resolve ok");
assert_eq!(
answer.ttl_seconds, 120,
"min TTL across records must be reported"
);
assert!(answer.targets.contains(&"203.0.113.1".to_string()));
assert!(answer.targets.contains(&"203.0.113.2".to_string()));
let _ = stop.send(());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_handles_empty_answer() {
let (addr, stop) = spawn_stub(vec![StubResponse::empty_noerror()]).await;
let answer = resolve_with_ttl("nothing.example.com.", addr, Duration::from_secs(2))
.await
.expect("empty NOERROR is Ok(empty)");
assert!(answer.targets.is_empty());
assert_eq!(answer.ttl_seconds, 0);
let _ = stop.send(());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_returns_timeout_error() {
let (addr, stop) = spawn_stub(vec![StubResponse::timeout()]).await;
let err = resolve_with_ttl("slow.example.com.", addr, Duration::from_millis(250))
.await
.expect_err("timeout must error");
assert_eq!(
err.kind(),
io::ErrorKind::TimedOut,
"swallowed query must surface as TimedOut, got {err:?}"
);
let _ = stop.send(());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_dedupes_targets() {
let (addr, stop) = spawn_stub(vec![StubResponse::ok_a(vec![
(Ipv4Addr::new(198, 51, 100, 7), 60),
(Ipv4Addr::new(198, 51, 100, 7), 60),
])])
.await;
let answer = resolve_with_ttl("dup.example.com.", addr, Duration::from_secs(2))
.await
.expect("resolve ok");
assert_eq!(answer.targets, vec!["198.51.100.7".to_string()]);
let _ = stop.send(());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_sorts_targets() {
let (addr, stop) = spawn_stub(vec![StubResponse::ok_a(vec![
(Ipv4Addr::new(203, 0, 113, 9), 60),
(Ipv4Addr::new(203, 0, 113, 1), 60),
(Ipv4Addr::new(203, 0, 113, 5), 60),
])])
.await;
let answer = resolve_with_ttl("sort.example.com.", addr, Duration::from_secs(2))
.await
.expect("resolve ok");
assert_eq!(
answer.targets,
vec![
"203.0.113.1".to_string(),
"203.0.113.5".to_string(),
"203.0.113.9".to_string(),
]
);
let _ = stop.send(());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_handles_servfail() {
let (addr, stop) = spawn_stub(vec![StubResponse::servfail()]).await;
let err = resolve_with_ttl("broken.example.com.", addr, Duration::from_secs(2))
.await
.expect_err("SERVFAIL must error");
assert_ne!(err.kind(), io::ErrorKind::TimedOut, "got {err:?}");
let _ = stop.send(());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resolve_handles_nxdomain() {
let (addr, stop) = spawn_stub(vec![StubResponse::nxdomain()]).await;
let answer = resolve_with_ttl("gone.example.com.", addr, Duration::from_secs(2))
.await
.expect("NXDOMAIN must surface as Ok(empty)");
assert!(answer.targets.is_empty(), "NXDOMAIN means no targets");
assert_eq!(answer.ttl_seconds, 0);
let _ = stop.send(());
}
#[test]
fn trust_anchor_path_populates_resolver_opts() {
let path = PathBuf::from("/etc/cellos/dnssec/operator-anchor.bin");
let opts = build_resolver_opts(Duration::from_secs(2), Some(path.clone()));
assert_eq!(
opts.trust_anchor,
Some(path),
"operator-supplied path must reach ResolverOpts.trust_anchor verbatim"
);
let opts_default = build_resolver_opts(Duration::from_secs(2), None);
assert_eq!(
opts_default.trust_anchor, None,
"no anchor path means we leave the field None for the default-anchor fallback"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn operator_anchor_path_is_actually_consulted() {
struct SyntheticAnchors {
path: PathBuf,
}
impl SyntheticAnchors {
fn as_trust_anchors(&self) -> TrustAnchors {
let mut anchors = TrustAnchors::iana_default();
anchors.bytes = b"unused-by-this-test".to_vec();
anchors.source = "synthetic-nonexistent.bin".to_string();
anchors.set_path_for_test(Some(self.path.clone()));
anchors
}
}
let synthetic = SyntheticAnchors {
path: PathBuf::from("/nonexistent/cellos/dnssec/operator-anchor.bin"),
};
let anchors = synthetic.as_trust_anchors();
let unreachable: SocketAddr = "127.0.0.1:9".parse().unwrap();
let err = resolve_with_ttl_validated(
"anchor-test.example.com.",
unreachable,
Duration::from_millis(200),
&anchors,
)
.await
.expect_err(
"nonexistent operator anchor path must surface as Err, not silently use IANA defaults",
);
let msg = format!("{err}");
assert!(
msg.contains("trust anchor parse failed"),
"operator-anchor wiring must reach hickory_proto::dnssec::TrustAnchors::from_file \
— silent IANA-default fallback would defeat Phase 3h.2 Q1 closure; got: {msg}"
);
}
#[test]
fn proof_secure_maps_to_validated() {
let result = proof_to_validation_result(Proof::Secure);
match result {
DnssecValidationResult::Validated { algorithm, key_tag } => {
assert_eq!(
algorithm, "unknown",
"fallback path keeps the documented placeholder"
);
assert_eq!(key_tag, 0, "fallback path keeps the documented placeholder");
}
other => panic!("Proof::Secure must map to Validated; got {other:?}"),
}
}
#[test]
fn proof_secure_with_rrsig_metadata_uses_real_values() {
let result = proof_to_validation_result_with_rrsig(
Proof::Secure,
Some(("RSASHA256".to_string(), 12345)),
);
match result {
DnssecValidationResult::Validated { algorithm, key_tag } => {
assert_eq!(
algorithm, "RSASHA256",
"real RRSIG metadata MUST replace the 'unknown' placeholder \
when supplied — Q2 closure regression"
);
assert_eq!(
key_tag, 12345,
"real RRSIG key_tag MUST replace the 0 placeholder \
when supplied — Q2 closure regression"
);
}
other => panic!("Proof::Secure must map to Validated; got {other:?}"),
}
}
#[test]
fn rrsig_metadata_ignored_for_non_secure_proofs() {
for proof in [Proof::Insecure, Proof::Bogus, Proof::Indeterminate] {
let result = proof_to_validation_result_with_rrsig(
proof,
Some(("RSASHA256".to_string(), 12345)),
);
match (proof, &result) {
(Proof::Insecure, DnssecValidationResult::Unsigned) => {}
(Proof::Bogus, DnssecValidationResult::Failed { reason })
if reason == "validation_failed" => {}
(Proof::Indeterminate, DnssecValidationResult::Failed { reason })
if reason == "validation_indeterminate" => {}
_ => panic!(
"metadata MUST be ignored for non-Secure proofs; \
got {proof:?} -> {result:?}"
),
}
}
}
#[test]
fn extract_rrsig_metadata_none_when_no_rrsigs() {
let a_record = hickory_resolver::proto::rr::Record::from_rdata(
"example.com.".parse().unwrap(),
300,
hickory_resolver::proto::rr::RData::A(hickory_resolver::proto::rr::rdata::A(
Ipv4Addr::new(203, 0, 113, 1),
)),
);
let metadata = extract_rrsig_metadata(
&[a_record],
&[
hickory_resolver::proto::rr::RecordType::A,
hickory_resolver::proto::rr::RecordType::AAAA,
],
);
assert!(
metadata.is_none(),
"answer set with no RRSIGs MUST return None so the caller \
stamps the documented placeholder rather than fabricating values"
);
}
#[test]
fn extract_rrsig_metadata_returns_real_values_for_a_query() {
use hickory_proto::dnssec::rdata::{DNSSECRData, SigInput, RRSIG};
use hickory_proto::dnssec::Algorithm;
use hickory_resolver::proto::rr::{RData, Record, RecordType, SerialNumber};
let input = SigInput {
type_covered: RecordType::A,
algorithm: Algorithm::RSASHA256,
num_labels: 2,
original_ttl: 300,
sig_expiration: SerialNumber::new(1_700_000_000),
sig_inception: SerialNumber::new(1_690_000_000),
key_tag: 54321,
signer_name: "example.com.".parse().unwrap(),
};
let rrsig = RRSIG::from_sig(input, vec![0u8; 32]);
let rrsig_record = Record::from_rdata(
"example.com.".parse().unwrap(),
300,
RData::DNSSEC(DNSSECRData::RRSIG(rrsig)),
);
let a_record = Record::from_rdata(
"example.com.".parse().unwrap(),
300,
RData::A(hickory_resolver::proto::rr::rdata::A(Ipv4Addr::new(
203, 0, 113, 1,
))),
);
let metadata = extract_rrsig_metadata(
&[a_record, rrsig_record],
&[RecordType::A, RecordType::AAAA],
);
assert_eq!(
metadata,
Some(("RSASHA256".to_string(), 54321)),
"extract_rrsig_metadata MUST surface the RRSIG's algorithm + key_tag — \
this is the load-bearing T1.A / P0-1 closure"
);
}
#[test]
fn extract_rrsig_metadata_skips_rrsig_for_other_types() {
use hickory_proto::dnssec::rdata::{DNSSECRData, SigInput, RRSIG};
use hickory_proto::dnssec::Algorithm;
use hickory_resolver::proto::rr::{RData, Record, RecordType, SerialNumber};
let cname_input = SigInput {
type_covered: RecordType::CNAME,
algorithm: Algorithm::ED25519,
num_labels: 2,
original_ttl: 300,
sig_expiration: SerialNumber::new(1_700_000_000),
sig_inception: SerialNumber::new(1_690_000_000),
key_tag: 60_999,
signer_name: "example.com.".parse().unwrap(),
};
let cname_rrsig = RRSIG::from_sig(cname_input, vec![0u8; 32]);
let cname_rrsig_record = Record::from_rdata(
"example.com.".parse().unwrap(),
300,
RData::DNSSEC(DNSSECRData::RRSIG(cname_rrsig)),
);
let metadata =
extract_rrsig_metadata(&[cname_rrsig_record], &[RecordType::A, RecordType::AAAA]);
assert!(
metadata.is_none(),
"RRSIG covering CNAME MUST NOT be reported when caller asked for A/AAAA — \
defensive type-cover discipline prevents misattributed signing parameters"
);
}
#[test]
fn proof_insecure_maps_to_unsigned() {
let result = proof_to_validation_result(Proof::Insecure);
assert!(
matches!(result, DnssecValidationResult::Unsigned),
"Proof::Insecure (zone known to be unsigned) must map to Unsigned; got {result:?}"
);
}
#[test]
fn proof_bogus_maps_to_failed_validation_failed() {
let result = proof_to_validation_result(Proof::Bogus);
match result {
DnssecValidationResult::Failed { reason } => {
assert_eq!(
reason, "validation_failed",
"Proof::Bogus → Failed must use the SIEM-stable code 'validation_failed'"
);
}
other => panic!("Proof::Bogus must map to Failed; got {other:?}"),
}
}
#[test]
fn proof_indeterminate_maps_to_failed_validation_indeterminate() {
let result = proof_to_validation_result(Proof::Indeterminate);
match result {
DnssecValidationResult::Failed { reason } => {
assert_eq!(
reason, "validation_indeterminate",
"Proof::Indeterminate → Failed must use the SIEM-stable code \
'validation_indeterminate' (distinct from validation_failed so an operator \
can grep for the 'validator could not reach a verdict' case separately from \
the 'validator rejected the chain' case)"
);
}
other => panic!("Proof::Indeterminate must map to Failed; got {other:?}"),
}
}
#[test]
fn worst_proof_demotes_on_first_bogus() {
let mut secure_record = hickory_resolver::proto::rr::Record::from_rdata(
"example.com.".parse().unwrap(),
300,
hickory_resolver::proto::rr::RData::A(hickory_resolver::proto::rr::rdata::A(
Ipv4Addr::new(203, 0, 113, 1),
)),
);
secure_record.proof = Proof::Secure;
let mut bogus_record = hickory_resolver::proto::rr::Record::from_rdata(
"example.com.".parse().unwrap(),
300,
hickory_resolver::proto::rr::RData::A(hickory_resolver::proto::rr::rdata::A(
Ipv4Addr::new(203, 0, 113, 2),
)),
);
bogus_record.proof = Proof::Bogus;
let records = vec![secure_record, bogus_record];
assert_eq!(
worst_proof(&records),
Proof::Bogus,
"any Bogus record must poison the whole answer set"
);
}
#[test]
fn worst_proof_returns_secure_only_when_all_records_secure() {
let mut r1 = hickory_resolver::proto::rr::Record::from_rdata(
"example.com.".parse().unwrap(),
300,
hickory_resolver::proto::rr::RData::A(hickory_resolver::proto::rr::rdata::A(
Ipv4Addr::new(203, 0, 113, 1),
)),
);
r1.proof = Proof::Secure;
let mut r2 = hickory_resolver::proto::rr::Record::from_rdata(
"example.com.".parse().unwrap(),
300,
hickory_resolver::proto::rr::RData::A(hickory_resolver::proto::rr::rdata::A(
Ipv4Addr::new(203, 0, 113, 2),
)),
);
r2.proof = Proof::Secure;
assert_eq!(
worst_proof(&[r1, r2]),
Proof::Secure,
"all-Secure record set must come back Secure"
);
}
#[test]
fn worst_proof_empty_returns_indeterminate() {
assert_eq!(worst_proof(&[]), Proof::Indeterminate);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn validated_servfail_surfaces_as_io_error_not_dnssec_failed() {
let (addr, _stop) = spawn_stub(vec![StubResponse::servfail()]).await;
let anchors = TrustAnchors::iana_default();
let err = resolve_with_ttl_validated(
"broken.example.com.",
addr,
Duration::from_secs(2),
&anchors,
)
.await
.expect_err("SERVFAIL must surface as io::Error, not as DnssecValidationResult::Failed");
assert_ne!(
err.kind(),
io::ErrorKind::TimedOut,
"SERVFAIL is not a timeout: {err:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn validated_unsigned_synthetic_upstream_does_not_panic() {
let (addr, stop) = spawn_stub(vec![StubResponse::ok_a(vec![(
Ipv4Addr::new(203, 0, 113, 5),
300,
)])])
.await;
let anchors = TrustAnchors::iana_default();
let result =
resolve_with_ttl_validated("api.example.com.", addr, Duration::from_secs(2), &anchors)
.await;
match result {
Ok(v) => {
let _ = v.validation;
assert_eq!(v.answer.resolver_addr, addr);
}
Err(e) => {
assert_ne!(
e.kind(),
io::ErrorKind::TimedOut,
"synthetic upstream answered; should not be a timeout: {e:?}"
);
}
}
let _ = stop.send(());
}
}