Skip to main content

seer_core/dns/
delegation.rs

1//! NS delegation health: compares the parent zone's delegation NS set
2//! against the zone's own authoritative NS RRset and probes each delegated
3//! server for lameness.
4//!
5//! Flow (see [`DelegationChecker::check`]):
6//! 1. Resolve the parent zone's NS set through the recursive resolver.
7//! 2. Query up to two responsive parent servers directly with recursion
8//!    disabled for the domain's NS — the delegation lives in the referral's
9//!    AUTHORITY section (or the ANSWER section when the parent server also
10//!    happens to be authoritative for the child). The union is `delegated_ns`.
11//!    A server counts as responsive only when it answers for the parent
12//!    zone — a delegation, or a negative answer backed by the zone's SOA; an
13//!    empty reply or a referral elsewhere is a lame parent, noted in the
14//!    warnings while the next one is asked.
15//! 3. Query each delegated NS directly (RD=0) for the domain's own NS RRset.
16//!    A server that fails to answer authoritatively for its own zone is lame
17//!    (refused / timeout / non-authoritative / empty). The union of
18//!    authoritative answers is `zone_ns`.
19//! 4. Compare the two sets case-insensitively with trailing dots normalized.
20//!
21//! [`DelegationChecker::delegated_ns`] runs steps 1–2 only, on a shorter
22//! deadline: [`crate::SmartLookup`] uses it to cross-check a WHOIS-only
23//! result's nameservers ([`DnsNameservers`]), since some registries' WHOIS
24//! lags their own zone.
25//!
26//! **Parent-zone simplification:** the parent zone is derived by stripping the
27//! domain's leftmost label (`example.com` → `com`, `example.co.uk` → `co.uk`).
28//! For multi-label public suffixes this is exactly the registrable parent, and
29//! for registrable domains it is the TLD — the common cases. A PSL-aware
30//! parent derivation (needed only for exotic zone cuts below private suffixes)
31//! is future work; for a subdomain whose immediate parent is not a zone cut,
32//! the NS lookup on the synthesized parent simply fails with a typed error.
33//!
34//! **Retry boundary (deliberate):** like [`crate::dns::resolver`], this module
35//! does not wrap queries in [`crate::retry::RetryPolicy`]. hickory performs
36//! its own retransmission (`opts.attempts = 2`) within the per-query timeout;
37//! a lameness probe wrapped in an outer retry loop would only multiply
38//! worst-case latency without changing the verdict.
39//!
40//! **SSRF:** delegated NS hostnames come from untrusted DNS data, so every
41//! address they resolve to is checked against
42//! [`crate::validation::describe_reserved_ip`] before a direct query is sent.
43//! Servers that resolve only to reserved/private space are skipped with a
44//! warning. Tests reach loopback fixtures through `#[cfg(test)]`-only seams
45//! (`allowing_private_hosts` / `with_port_map` / `with_recursive_upstream`);
46//! the production validation path is never weakened.
47//!
48//! **Bounded work:** at most [`MAX_PARENT_SERVERS_TRIED`] parent servers are
49//! contacted (stopping after [`PARENT_SERVERS_WANTED`] respond), at most
50//! [`MAX_NS_PROBED`] delegated servers are probed (concurrently), each
51//! address lookup and query runs under the per-query timeout, and each server
52//! is asked on one vetted address at a time, IPv4 first: an address this
53//! host has no route to (IPv6 on an IPv4-only host, or the reverse) fails at
54//! once and the server's next address is tried. The whole check runs under
55//! one deadline of [`CHECK_BUDGET_TIMEOUTS`] per-query timeouts; a probe
56//! still running when it passes is reported as not probed.
57
58use std::collections::BTreeSet;
59use std::net::IpAddr;
60use std::time::Duration;
61
62use hickory_resolver::config::ResolverOpts;
63use hickory_resolver::net::runtime::TokioRuntimeProvider;
64use hickory_resolver::net::{DnsError, NetError};
65use hickory_resolver::proto::op::ResponseCode;
66use hickory_resolver::proto::rr::{RData as HickoryRData, Record, RecordType as HickoryRecordType};
67use hickory_resolver::TokioResolver;
68use serde::{Deserialize, Serialize};
69use tracing::{debug, instrument};
70
71use super::resolver::{
72    apply_standard_opts, build_plain_resolver, fqdn, google_or_pinned, single_server_config,
73};
74use super::DEFAULT_DNS_TIMEOUT;
75use crate::error::{Result, SeerError};
76use crate::validation::normalize_domain;
77
78/// The whole check's deadline, in per-query timeouts. An address lookup is
79/// held to one timeout, but a lookup or query that hickory retransmits
80/// (`attempts = 2`) may take two: the parent NS lookup (2), then
81/// [`PARENT_SERVERS_WANTED`] parent servers that each answer only on the
82/// retransmission (3 each: lookup + query), then the concurrent probes (3).
83/// Without the bound, silent servers held the caller for up to 17 timeouts
84/// (85s at the default 5s): the parent phase is sequential over
85/// [`MAX_PARENT_SERVERS_TRIED`] servers.
86const CHECK_BUDGET_TIMEOUTS: u32 = 2 + 3 * PARENT_SERVERS_WANTED as u32 + 3;
87
88/// Overall deadline of [`DelegationChecker::delegated_ns`], in per-query
89/// timeouts. It is supplementary detail on a lookup, so it gets far less
90/// than a full [`DelegationChecker::check`]: a parent zone that answers at
91/// all does so well within it, and one that doesn't is not worth waiting on.
92const DELEGATED_NS_BUDGET_TIMEOUTS: u32 = 2;
93
94/// Maximum delegated NS servers probed for lameness. Delegations larger than
95/// this are unusual; the cap bounds total probe work and a warning notes the
96/// truncation.
97const MAX_NS_PROBED: usize = 6;
98
99/// Maximum parent-zone servers contacted while hunting for responsive ones.
100const MAX_PARENT_SERVERS_TRIED: usize = 4;
101
102/// Stop querying parent servers once this many have answered.
103const PARENT_SERVERS_WANTED: usize = 2;
104
105/// Why a delegated nameserver is lame, for consumers that act on the cause
106/// without parsing [`LameNs::reason`] (the security audit grades a server
107/// that answered wrongly apart from one that never answered).
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "kebab-case")]
110pub enum LameKind {
111    /// Answered NXDOMAIN for its own zone.
112    Nxdomain,
113    /// Answered authoritatively, but with no NS records.
114    NoNsRecords,
115    /// Answered the NS set without the AA bit (a cache, not the authority).
116    NotAuthoritative,
117    /// Referred the query elsewhere instead of answering.
118    Referral,
119    /// Answered with an empty, non-authoritative response.
120    EmptyAnswer,
121    /// Answered with an error RCODE (REFUSED, SERVFAIL, …).
122    ErrorRcode,
123    /// Gave no usable response (timeout, transport failure) — the server may
124    /// be dead, or the probe may have been lost.
125    Unresponsive,
126}
127
128/// A delegated nameserver that failed to answer authoritatively for its own
129/// zone, with a structured [`LameKind`] and a human-readable reason.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct LameNs {
132    /// Nameserver host name (normalized: lowercase, no trailing dot).
133    pub host: String,
134    /// What made the server lame.
135    pub kind: LameKind,
136    /// Why the server is considered lame, for display.
137    pub reason: String,
138}
139
140/// Result of an NS delegation health check.
141///
142/// All NS host names are normalized (lowercase, trailing dot stripped) and
143/// sorted, so the report is deterministic and set comparisons are exact.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct DelegationReport {
146    /// The checked domain (normalized).
147    pub domain: String,
148    /// The parent zone the delegation was read from (domain minus its
149    /// leftmost label — see the module docs for this simplification).
150    pub parent_zone: String,
151    /// Parent-zone servers that answered the delegation query.
152    pub parent_server_queried: Vec<String>,
153    /// The parent's view: NS names in the delegation (referral) for the domain.
154    pub delegated_ns: Vec<String>,
155    /// The authoritative view: union of NS RRsets returned (with the AA bit
156    /// set) by the delegated servers themselves.
157    pub zone_ns: Vec<String>,
158    /// True when the parent and zone NS sets match exactly and no probed
159    /// server is lame.
160    pub in_sync: bool,
161    /// NS names the parent lists but the zone's own RRset omits.
162    pub missing_from_zone: Vec<String>,
163    /// NS names the zone's RRset lists but the parent delegation omits.
164    pub missing_from_parent: Vec<String>,
165    /// Delegated servers that failed to answer authoritatively.
166    pub lame: Vec<LameNs>,
167    /// Non-fatal anomalies (unresolvable glue, skipped servers, truncated
168    /// probe list, ...).
169    pub warnings: Vec<String>,
170}
171
172/// A registry's WHOIS nameserver list checked against the live parent-zone
173/// delegation ([`DelegationChecker::delegated_ns`]).
174///
175/// Some registries' WHOIS lags their own zone (NIC Togo listed 2 of the 4
176/// nameservers `.tg` delegates for `nic.tg`), and a WHOIS-only lookup has no
177/// RDAP record to cross-check against, so [`crate::SmartLookup`] attaches
178/// this to WHOIS-sourced results. Names are normalized (lowercase, no
179/// trailing dot) and sorted.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct DnsNameservers {
182    /// NS names the parent zone delegates for the domain.
183    pub delegated: Vec<String>,
184    /// True when WHOIS lists exactly the delegated set.
185    pub in_sync: bool,
186    /// Delegated NS names WHOIS does not list.
187    pub missing_from_whois: Vec<String>,
188    /// NS names WHOIS lists that the parent does not delegate.
189    pub missing_from_dns: Vec<String>,
190}
191
192impl DnsNameservers {
193    /// Compares `whois` (any case, trailing dots allowed) with the
194    /// `delegated` set.
195    pub fn compare(whois: &[String], delegated: Vec<String>) -> Self {
196        let norm = |n: &String| n.trim_end_matches('.').to_ascii_lowercase();
197        let whois: BTreeSet<String> = whois.iter().map(norm).collect();
198        let delegated: BTreeSet<String> = delegated.iter().map(norm).collect();
199        let missing_from_whois: Vec<String> = delegated.difference(&whois).cloned().collect();
200        let missing_from_dns: Vec<String> = whois.difference(&delegated).cloned().collect();
201        Self {
202            in_sync: missing_from_whois.is_empty() && missing_from_dns.is_empty(),
203            delegated: delegated.into_iter().collect(),
204            missing_from_whois,
205            missing_from_dns,
206        }
207    }
208
209    /// True when WHOIS lists no nameservers but the parent delegates some.
210    /// Many registries' WHOIS omits nameservers entirely, so unlike a partial
211    /// list this is no sign of a stale record.
212    pub fn whois_lists_none(&self) -> bool {
213        // Any WHOIS name either shrinks `missing_from_whois` below the
214        // delegated set or lands in `missing_from_dns`.
215        !self.delegated.is_empty()
216            && self.missing_from_dns.is_empty()
217            && self.missing_from_whois.len() == self.delegated.len()
218    }
219}
220
221/// The parent zone's answer for a delegation (steps 1–2 of
222/// [`DelegationChecker::check`]).
223struct ParentDelegation {
224    /// Parent-zone servers that answered.
225    servers_queried: Vec<String>,
226    /// Union of the NS names they delegate (normalized, sorted).
227    delegated: BTreeSet<String>,
228    warnings: Vec<String>,
229}
230
231/// Classified result of a single non-recursive NS query against one server.
232///
233/// hickory folds a referral for an NS query (empty ANSWER, matching NS
234/// records in AUTHORITY) into a successful `Lookup` — `contains_answer`
235/// scans all sections for the queried type — so both the `Ok` and the
236/// `NoRecordsFound` paths are normalized into the same `Response` shape
237/// here, keeping ANSWER and AUTHORITY NS sets separate.
238#[derive(Debug)]
239enum DirectNs {
240    /// The server produced a DNS response (any RCODE except hard errors).
241    Response {
242        /// The response AA bit. `false` for the `NoRecordsFound` path, which
243        /// does not carry the header — for this module's queries that path
244        /// only occurs for NODATA/NXDOMAIN responses, which are never
245        /// treated as authoritative NS answers anyway.
246        authoritative: bool,
247        response_code: ResponseCode,
248        /// NS targets for the queried domain from the ANSWER section.
249        answer_ns: Vec<String>,
250        /// NS targets for the queried domain from the AUTHORITY section
251        /// (where a parent publishes the delegation / referral).
252        authority_ns: Vec<String>,
253        /// Owner of an SOA record in AUTHORITY (normalized): the zone a
254        /// negative answer speaks for (RFC 2308). A server that is not
255        /// authoritative for the name's zone has none to give.
256        soa_zone: Option<String>,
257    },
258    /// The server responded with an error RCODE (REFUSED, SERVFAIL, ...).
259    Rcode(ResponseCode),
260    /// No usable response (timeout / transport failure), with the reason.
261    Unreachable(String),
262    /// The probe packet never left this host (no route for the address
263    /// family, or no socket of that family at all — e.g. an IPv6-only
264    /// server probed from an IPv4-only host).
265    /// The same epistemic state as unresolvable glue: nothing is known
266    /// about the server's authority, so this maps to a skip, not lameness.
267    LocalNoRoute(String),
268}
269
270/// Per-server outcome of a lameness probe.
271#[derive(Debug)]
272enum ProbeOutcome {
273    /// Authoritative NS answer — contributes to `zone_ns`.
274    Healthy(Vec<String>),
275    /// The server is lame: its kind and the display reason.
276    Lame(LameKind, String),
277    /// The server could not be probed at all (e.g. unresolvable glue);
278    /// reported as a warning, not as lameness.
279    Skipped(String),
280}
281
282/// Checks NS delegation health for a domain.
283///
284/// Construct with [`DelegationChecker::new`] (defaults) or
285/// [`DelegationChecker::from_config`] (honors `~/.seer/config.toml`
286/// `timeouts.dns_secs`), then call [`check`](DelegationChecker::check).
287#[derive(Clone)]
288pub struct DelegationChecker {
289    /// Per-query timeout for both recursive and direct queries.
290    timeout: Duration,
291    /// Recursive resolver used to discover the parent zone's NS set and to
292    /// resolve nameserver host addresses ("glue", loosely — resolution goes
293    /// through recursion rather than the parent's additional section).
294    recursive: TokioResolver,
295    /// Test-only: pin the recursive resolver to a loopback mock.
296    #[cfg(test)]
297    recursive_upstream: Option<(IpAddr, u16)>,
298    /// Test-only: per-host port override for direct queries, so each mock
299    /// "server" can live on its own ephemeral loopback port.
300    #[cfg(test)]
301    port_map: Option<std::collections::HashMap<String, u16>>,
302    /// Test-only: skip the SSRF/reserved-IP validation on direct targets.
303    #[cfg(test)]
304    allow_private_hosts: bool,
305    /// Test-only: addresses whose direct queries fail as if this host had
306    /// no route to them (see `with_unroutable`).
307    #[cfg(test)]
308    unroutable: Vec<IpAddr>,
309    /// Test-only: the whole check's deadline, instead of
310    /// [`CHECK_BUDGET_TIMEOUTS`] per-query timeouts.
311    #[cfg(test)]
312    budget: Option<Duration>,
313}
314
315impl std::fmt::Debug for DelegationChecker {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        f.debug_struct("DelegationChecker")
318            .field("timeout", &self.timeout)
319            .finish()
320    }
321}
322
323impl Default for DelegationChecker {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329impl DelegationChecker {
330    /// Creates a delegation checker with default settings (5s per-query
331    /// timeout, Google DNS as the recursive resolver).
332    pub fn new() -> Self {
333        Self {
334            timeout: DEFAULT_DNS_TIMEOUT,
335            recursive: build_recursive_resolver(DEFAULT_DNS_TIMEOUT, None),
336            #[cfg(test)]
337            recursive_upstream: None,
338            #[cfg(test)]
339            port_map: None,
340            #[cfg(test)]
341            allow_private_hosts: false,
342            #[cfg(test)]
343            unroutable: Vec::new(),
344            #[cfg(test)]
345            budget: None,
346        }
347    }
348
349    /// Builds a checker honoring `~/.seer/config.toml` settings.
350    ///
351    /// Reads `timeouts.dns_secs` (already clamped to 1–60s by
352    /// [`crate::config::SeerConfig::load`]). Sugar over
353    /// [`DelegationChecker::with_timeout`].
354    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
355        Self::new().with_timeout(config.dns_timeout())
356    }
357
358    /// Sets the per-query timeout (applies to recursive lookups, parent
359    /// delegation queries, and lameness probes alike).
360    pub fn with_timeout(mut self, timeout: Duration) -> Self {
361        self.timeout = timeout;
362        self.recursive = build_recursive_resolver(timeout, self.recursive_upstream());
363        self
364    }
365
366    /// Test-only: point the recursive resolver at a loopback mock server.
367    #[cfg(test)]
368    fn with_recursive_upstream(mut self, ip: IpAddr, port: u16) -> Self {
369        self.recursive_upstream = Some((ip, port));
370        self.recursive = build_recursive_resolver(self.timeout, self.recursive_upstream);
371        self
372    }
373
374    /// Test-only: route direct queries for the given (normalized) host names
375    /// to per-host mock ports instead of port 53.
376    #[cfg(test)]
377    fn with_port_map(mut self, map: std::collections::HashMap<String, u16>) -> Self {
378        self.port_map = Some(map);
379        self
380    }
381
382    /// Test-only: allow direct queries to loopback/private hosts (mock servers).
383    #[cfg(test)]
384    fn allowing_private_hosts(mut self) -> Self {
385        self.allow_private_hosts = true;
386        self
387    }
388
389    /// Test-only: direct queries to `addrs` fail at once with a local
390    /// no-route error, as on a host without a route to that family.
391    #[cfg(test)]
392    fn with_unroutable(mut self, addrs: &[IpAddr]) -> Self {
393        self.unroutable.extend_from_slice(addrs);
394        self
395    }
396
397    /// Test-only: hold the whole check to `budget`.
398    #[cfg(test)]
399    fn with_budget(mut self, budget: Duration) -> Self {
400        self.budget = Some(budget);
401        self
402    }
403
404    /// Steps 1–2 of [`check`](Self::check): the parent zone's NS set, then the
405    /// delegation for `domain` from up to [`PARENT_SERVERS_WANTED`] of its
406    /// servers. A server counts only when it answers for the parent zone
407    /// (see [`parent_answer`]); a lame one is a warning and the next is
408    /// asked, up to [`MAX_PARENT_SERVERS_TRIED`] within the deadline. Errors
409    /// when the parent has no NS set or no parent server answers; an empty
410    /// delegation is a warning.
411    async fn parent_delegation(
412        &self,
413        domain: &str,
414        parent_zone: &str,
415        deadline: tokio::time::Instant,
416        budget: Duration,
417    ) -> Result<ParentDelegation> {
418        let mut warnings = Vec::new();
419        let out_of_time = || {
420            SeerError::DnsError(format!(
421                "delegation check for {domain} gave up after {budget:?} without a parent-zone answer"
422            ))
423        };
424
425        // 1. Parent zone NS set via the recursive resolver.
426        let parent_hosts = tokio::time::timeout_at(deadline, self.recursive_ns_set(parent_zone))
427            .await
428            .map_err(|_| out_of_time())??;
429        if parent_hosts.is_empty() {
430            return Err(SeerError::DnsError(format!(
431                "parent zone {} has no NS records — cannot locate the delegation for {}",
432                parent_zone, domain
433            )));
434        }
435
436        // 2. Ask up to PARENT_SERVERS_WANTED responsive parent servers for the
437        //    delegation (RD=0). Referrals carry the NS set in AUTHORITY; a
438        //    parent that is also authoritative for the child answers in ANSWER.
439        let mut parent_server_queried = Vec::new();
440        let mut delegated: BTreeSet<String> = BTreeSet::new();
441        for host in parent_hosts.iter().take(MAX_PARENT_SERVERS_TRIED) {
442            if parent_server_queried.len() >= PARENT_SERVERS_WANTED {
443                break;
444            }
445            let Ok(asked) = tokio::time::timeout_at(deadline, self.ask_host(host, domain)).await
446            else {
447                warnings.push(format!(
448                    "stopped asking parent servers: the check's {budget:?} budget ran out"
449                ));
450                break;
451            };
452            let direct = match asked {
453                Ok(direct) => direct,
454                Err(reason) => {
455                    warnings.push(format!("skipped parent server {}: {}", host, reason));
456                    continue;
457                }
458            };
459            // Only a real answer counts toward PARENT_SERVERS_WANTED: a lame
460            // server's empty reply would otherwise stop the hunt before a
461            // healthy one is asked.
462            match parent_answer(direct, domain) {
463                ParentAnswer::Delegation(ns) => {
464                    delegated.extend(ns);
465                    parent_server_queried.push(host.clone());
466                }
467                ParentAnswer::Negative(code) => {
468                    if code == ResponseCode::NXDomain {
469                        warnings.push(format!(
470                            "parent server {} returned NXDOMAIN for {}",
471                            host, domain
472                        ));
473                    }
474                    parent_server_queried.push(host.clone());
475                }
476                ParentAnswer::Lame(reason) => {
477                    warnings.push(format!("parent server {host} {reason}"));
478                }
479            }
480        }
481
482        if parent_server_queried.is_empty() {
483            return Err(SeerError::DnsError(format!(
484                "no parent-zone ({}) nameserver answered the delegation query for {}",
485                parent_zone, domain
486            )));
487        }
488        if delegated.is_empty() {
489            warnings.push(format!(
490                "parent zone {} returned no NS delegation for {} — the domain may not be delegated",
491                parent_zone, domain
492            ));
493        }
494
495        Ok(ParentDelegation {
496            servers_queried: parent_server_queried,
497            delegated,
498            warnings,
499        })
500    }
501
502    /// The NS set the parent zone delegates for `domain` (normalized:
503    /// lowercase, no trailing dot; sorted) — the registry's live view, without
504    /// [`check`](Self::check)'s per-server lameness probes. Bounded by two
505    /// per-query timeouts overall (`DELEGATED_NS_BUDGET_TIMEOUTS`), far less
506    /// than a full check: it is supplementary detail on a lookup.
507    #[instrument(skip(self), fields(domain = %domain))]
508    pub async fn delegated_ns(&self, domain: &str) -> Result<Vec<String>> {
509        let domain = normalize_domain(domain)?;
510        let parent_zone = parent_zone_of(&domain);
511        let budget = self.timeout.saturating_mul(DELEGATED_NS_BUDGET_TIMEOUTS);
512        let deadline = tokio::time::Instant::now() + budget;
513        let delegation = self
514            .parent_delegation(&domain, &parent_zone, deadline, budget)
515            .await?;
516        Ok(delegation.delegated.into_iter().collect())
517    }
518
519    #[cfg(not(test))]
520    fn budget(&self) -> Duration {
521        self.timeout.saturating_mul(CHECK_BUDGET_TIMEOUTS)
522    }
523
524    #[cfg(test)]
525    fn budget(&self) -> Duration {
526        self.budget
527            .unwrap_or_else(|| self.timeout.saturating_mul(CHECK_BUDGET_TIMEOUTS))
528    }
529
530    #[cfg(test)]
531    fn simulated_no_route(&self, ip: IpAddr) -> bool {
532        self.unroutable.contains(&ip)
533    }
534
535    #[cfg(not(test))]
536    fn simulated_no_route(&self, _ip: IpAddr) -> bool {
537        false
538    }
539
540    #[cfg(test)]
541    fn recursive_upstream(&self) -> Option<(IpAddr, u16)> {
542        self.recursive_upstream
543    }
544
545    #[cfg(not(test))]
546    fn recursive_upstream(&self) -> Option<(IpAddr, u16)> {
547        None
548    }
549
550    #[cfg(test)]
551    fn allow_private(&self) -> bool {
552        self.allow_private_hosts
553    }
554
555    #[cfg(not(test))]
556    fn allow_private(&self) -> bool {
557        false
558    }
559
560    /// Port used for a direct query to `host`. Always 53 in production; the
561    /// `#[cfg(test)]` port map lets each mock server bind its own ephemeral
562    /// loopback port.
563    #[cfg(test)]
564    fn direct_port(&self, host: &str) -> u16 {
565        self.port_map
566            .as_ref()
567            .and_then(|map| map.get(&normalize_ns_name(host)).copied())
568            .unwrap_or(53)
569    }
570
571    #[cfg(not(test))]
572    fn direct_port(&self, _host: &str) -> u16 {
573        53
574    }
575
576    /// Runs the delegation health check for `domain`.
577    ///
578    /// # Arguments
579    /// * `domain` - The domain to check (e.g. "example.com"); normalized first
580    ///
581    /// # Returns
582    /// * `Ok(DelegationReport)` - the comparison + lameness report
583    /// * `Err(SeerError)` - when the input is invalid, the parent zone's NS
584    ///   set cannot be resolved, or no parent server answers the delegation
585    ///   query (without parent data there is nothing to compare)
586    #[instrument(skip(self), fields(domain = %domain))]
587    pub async fn check(&self, domain: &str) -> Result<DelegationReport> {
588        let domain = normalize_domain(domain)?;
589        let parent_zone = parent_zone_of(&domain);
590        let budget = self.budget();
591        let deadline = tokio::time::Instant::now() + budget;
592
593        // 1–2. The parent zone's delegation.
594        let ParentDelegation {
595            servers_queried: parent_server_queried,
596            delegated,
597            mut warnings,
598        } = self
599            .parent_delegation(&domain, &parent_zone, deadline, budget)
600            .await?;
601
602        // 3. Probe the delegated servers (bounded, concurrent) for lameness.
603        let probe_hosts: Vec<String> = delegated.iter().take(MAX_NS_PROBED).cloned().collect();
604        if delegated.len() > MAX_NS_PROBED {
605            warnings.push(format!(
606                "probing only {} of {} delegated nameservers",
607                MAX_NS_PROBED,
608                delegated.len()
609            ));
610        }
611        // Each probe under the check's deadline, so one that runs out is
612        // reported on its own and the probes that finished are kept.
613        let outcomes = futures::future::join_all(probe_hosts.iter().map(|host| async {
614            tokio::time::timeout_at(deadline, self.probe_delegated_ns(host, &domain))
615                .await
616                .unwrap_or_else(|_| {
617                    ProbeOutcome::Skipped(format!(
618                        "not probed within the check's {budget:?} budget"
619                    ))
620                })
621        }))
622        .await;
623
624        let mut zone_ns: BTreeSet<String> = BTreeSet::new();
625        let mut lame = Vec::new();
626        for (host, outcome) in probe_hosts.iter().zip(outcomes) {
627            match outcome {
628                ProbeOutcome::Healthy(ns) => zone_ns.extend(ns),
629                ProbeOutcome::Lame(kind, reason) => lame.push(LameNs {
630                    host: host.clone(),
631                    kind,
632                    reason,
633                }),
634                ProbeOutcome::Skipped(reason) => {
635                    warnings.push(format!("could not probe {}: {}", host, reason));
636                }
637            }
638        }
639
640        // 4. Set comparison. When no server produced an authoritative answer
641        //    there is no zone view to diff against — flag it instead of
642        //    reporting every delegated NS as "missing from zone".
643        let (missing_from_zone, missing_from_parent) = if zone_ns.is_empty() {
644            if !delegated.is_empty() {
645                warnings.push(
646                    "no delegated nameserver returned an authoritative NS answer — \
647                     parent/zone set comparison skipped"
648                        .to_string(),
649                );
650            }
651            (Vec::new(), Vec::new())
652        } else {
653            (
654                delegated.difference(&zone_ns).cloned().collect(),
655                zone_ns.difference(&delegated).cloned().collect(),
656            )
657        };
658
659        let in_sync = !delegated.is_empty()
660            && !zone_ns.is_empty()
661            && missing_from_zone.is_empty()
662            && missing_from_parent.is_empty()
663            && lame.is_empty();
664
665        lame.sort_by(|a, b| a.host.cmp(&b.host));
666
667        Ok(DelegationReport {
668            domain,
669            parent_zone,
670            parent_server_queried,
671            delegated_ns: delegated.into_iter().collect(),
672            zone_ns: zone_ns.into_iter().collect(),
673            in_sync,
674            missing_from_zone,
675            missing_from_parent,
676            lame,
677            warnings,
678        })
679    }
680
681    /// Resolves a zone's NS set through the recursive resolver, returning
682    /// normalized host names (sorted, deduplicated). NXDOMAIN/NODATA fold to
683    /// an empty set; transport failures surface as errors.
684    async fn recursive_ns_set(&self, zone: &str) -> Result<Vec<String>> {
685        match self
686            .recursive
687            .lookup(fqdn(zone), HickoryRecordType::NS)
688            .await
689        {
690            Ok(lookup) => {
691                let set: BTreeSet<String> =
692                    ns_targets_for(lookup.answers(), zone).into_iter().collect();
693                Ok(set.into_iter().collect())
694            }
695            Err(e) if e.is_no_records_found() => Ok(Vec::new()),
696            Err(e) => Err(SeerError::DnsError(format!(
697                "NS lookup for parent zone {} failed: {}",
698                zone, e
699            ))),
700        }
701    }
702
703    /// Resolves the usable (public, unless the test seam allows private)
704    /// addresses of a nameserver host, IPv4 first ([`crate::net::ipv4_first`]). Errors
705    /// carry a human-readable reason for the report's warnings.
706    async fn resolve_host_ips(&self, host: &str) -> std::result::Result<Vec<IpAddr>, String> {
707        // The resolver re-sends a query that timed out (`attempts`), so only
708        // this deadline holds the lookup to one per-query timeout.
709        let response = tokio::time::timeout(self.timeout, self.recursive.lookup_ip(fqdn(host)))
710            .await
711            .unwrap_or(Err(NetError::Timeout))
712            .map_err(|e| format!("could not resolve {}: {}", host, e))?;
713        let ips: Vec<IpAddr> = response.iter().collect();
714        if ips.is_empty() {
715            return Err(format!("{} did not resolve to any address", host));
716        }
717        // SSRF protection: NS host names come from untrusted DNS data, so
718        // reserved/private targets are refused. The test seam is
719        // `#[cfg(test)]`-only; production always validates.
720        let (vetted, refused) = partition_reserved(&ips, self.allow_private());
721        if vetted.is_empty() {
722            return Err(match refused.first() {
723                Some((_, reason)) => format!("{} resolves to a blocked address ({})", host, reason),
724                None => format!("{} did not resolve to a usable address", host),
725            });
726        }
727        let mut vetted = vetted;
728        crate::net::ipv4_first(&mut vetted, |ip| *ip);
729        Ok(vetted)
730    }
731
732    /// Asks `host` for `domain`'s NS set (RD=0) on its addresses in turn,
733    /// until a query leaves this host: an address with no local route
734    /// ([`DirectNs::LocalNoRoute`]) fails at once and the next is tried —
735    /// an IPv6-only server from an IPv4-only host, or the reverse. `Err` is
736    /// an address-lookup failure; when no address is routable, the last
737    /// `LocalNoRoute`.
738    async fn ask_host(&self, host: &str, domain: &str) -> std::result::Result<DirectNs, String> {
739        let mut last = None;
740        for ip in self.resolve_host_ips(host).await? {
741            debug!(server = %host, %ip, "direct NS query");
742            match self.direct_ns_query(host, ip, domain).await {
743                DirectNs::LocalNoRoute(reason) => {
744                    last = Some(DirectNs::LocalNoRoute(format!("{ip}: {reason}")));
745                }
746                direct => return Ok(direct),
747            }
748        }
749        // `resolve_host_ips` returns at least one address.
750        Ok(last.unwrap_or_else(|| DirectNs::Unreachable("no address to query".to_string())))
751    }
752
753    /// Sends a single non-recursive NS query for `domain` to the server at
754    /// `ip` and classifies the outcome. Never fails hard: construction and
755    /// transport errors fold into [`DirectNs::Unreachable`] so callers can
756    /// record them per-server.
757    async fn direct_ns_query(&self, host: &str, ip: IpAddr, domain: &str) -> DirectNs {
758        if self.simulated_no_route(ip) {
759            return DirectNs::LocalNoRoute("network is unreachable (simulated)".to_string());
760        }
761        let resolver = match self.build_direct_resolver(host, ip) {
762            Ok(resolver) => resolver,
763            Err(e) => return DirectNs::Unreachable(format!("resolver construction failed: {}", e)),
764        };
765        match resolver.lookup(fqdn(domain), HickoryRecordType::NS).await {
766            // The Lookup preserves the raw response message, so the AA bit
767            // is the server's own claim of authority, and a referral's NS
768            // records are still sitting in the AUTHORITY section.
769            Ok(lookup) => {
770                let message = lookup.message();
771                DirectNs::Response {
772                    authoritative: message.metadata.authoritative,
773                    response_code: message.metadata.response_code,
774                    answer_ns: ns_targets_for(&message.answers, domain),
775                    authority_ns: ns_targets_for(&message.authorities, domain),
776                    soa_zone: soa_zone_in(&message.authorities),
777                }
778            }
779            Err(e) => classify_net_error(e, domain),
780        }
781    }
782
783    /// Builds a one-shot stub resolver pinned to `ip` with recursion
784    /// disabled, mirroring the construction pattern in
785    /// [`crate::dns::resolver`]. UDP with TCP fallback, so truncated NS sets
786    /// retry over TCP.
787    fn build_direct_resolver(&self, host: &str, ip: IpAddr) -> Result<TokioResolver> {
788        let config = single_server_config(ip, self.direct_port(host), true);
789        let mut builder =
790            TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
791        apply_direct_opts(builder.options_mut(), self.timeout);
792        builder
793            .build()
794            .map_err(|e| SeerError::DnsError(format!("failed to construct DNS resolver: {}", e)))
795    }
796
797    /// Probes one delegated nameserver: sends the RD=0 NS query
798    /// ([`ask_host`](Self::ask_host)) and classifies the result.
799    async fn probe_delegated_ns(&self, host: &str, domain: &str) -> ProbeOutcome {
800        match self.ask_host(host, domain).await {
801            Ok(direct) => outcome_for_direct(direct),
802            // Unresolvable "glue" is a warning, not lameness: the server was
803            // never reached, so nothing is known about its authority.
804            Err(reason) => ProbeOutcome::Skipped(reason),
805        }
806    }
807}
808
809/// Maps a direct-query result onto the per-server probe verdict. Pure so the
810/// lameness-vs-skip policy is unit-testable without a live probe.
811fn outcome_for_direct(direct: DirectNs) -> ProbeOutcome {
812    match direct {
813        DirectNs::Response {
814            response_code: ResponseCode::NXDomain,
815            ..
816        } => ProbeOutcome::Lame(
817            LameKind::Nxdomain,
818            "returned NXDOMAIN for its own zone".to_string(),
819        ),
820        DirectNs::Response {
821            authoritative: true,
822            answer_ns,
823            ..
824        } if !answer_ns.is_empty() => ProbeOutcome::Healthy(answer_ns),
825        DirectNs::Response {
826            authoritative: true,
827            ..
828        } => ProbeOutcome::Lame(
829            LameKind::NoNsRecords,
830            "authoritative answer contained no NS records".to_string(),
831        ),
832        DirectNs::Response { answer_ns, .. } if !answer_ns.is_empty() => ProbeOutcome::Lame(
833            LameKind::NotAuthoritative,
834            "answered non-authoritatively (AA bit not set)".to_string(),
835        ),
836        DirectNs::Response { authority_ns, .. } if !authority_ns.is_empty() => ProbeOutcome::Lame(
837            LameKind::Referral,
838            "not authoritative — referred the query elsewhere".to_string(),
839        ),
840        DirectNs::Response { .. } => ProbeOutcome::Lame(
841            LameKind::EmptyAnswer,
842            "returned an empty answer (no NS records)".to_string(),
843        ),
844        DirectNs::Rcode(code) => ProbeOutcome::Lame(
845            LameKind::ErrorRcode,
846            format!("refused the query ({})", code),
847        ),
848        DirectNs::Unreachable(reason) => ProbeOutcome::Lame(
849            LameKind::Unresponsive,
850            format!("no usable response: {}", reason),
851        ),
852        DirectNs::LocalNoRoute(reason) => ProbeOutcome::Skipped(format!(
853            "no route from this host ({}) — server not probed (e.g. an IPv6-only nameserver on an IPv4-only host)",
854            reason
855        )),
856    }
857}
858
859/// Options for a direct (RD=0) query to one server: the shared option set
860/// (timeout, attempts, no hosts file, pinned server order — hand-copied here
861/// it had drifted and lacked the ordering) with recursion disabled, since
862/// delegation data must come from the server's own authority, not from
863/// recursion or a forwarder's cache.
864fn apply_direct_opts(opts: &mut ResolverOpts, timeout: Duration) {
865    apply_standard_opts(opts, timeout);
866    opts.recursion_desired = false;
867}
868
869/// Builds the recursive resolver: Google DNS (UDP+TCP) in production, or a
870/// pinned loopback upstream for the `#[cfg(test)]` seam. Shared with the
871/// trace walker (`dns::trace`), which resolves glueless nameservers through
872/// it. Built by `resolver::build_plain_resolver`, the one builder for plain
873/// UDP/TCP upstreams, so the option set cannot drift from the main
874/// resolver's (a hand-copied version once lacked the pinned server ordering).
875pub(crate) fn build_recursive_resolver(
876    timeout: Duration,
877    upstream: Option<(IpAddr, u16)>,
878) -> TokioResolver {
879    build_plain_resolver(google_or_pinned(upstream), timeout)
880}
881
882/// Classifies a hickory lookup error into a [`DirectNs`] outcome, extracting
883/// referral NS names for `domain` from the AUTHORITY section when present.
884/// The `NoRecordsFound` path only occurs when no NS record for the domain
885/// exists in any section (NODATA / NXDOMAIN) — see [`DirectNs`].
886fn classify_net_error(err: NetError, domain: &str) -> DirectNs {
887    match err {
888        NetError::Dns(DnsError::NoRecordsFound(no_records)) => {
889            let authorities = no_records.authorities.as_deref().unwrap_or_default();
890            DirectNs::Response {
891                authoritative: false,
892                response_code: no_records.response_code,
893                answer_ns: Vec::new(),
894                authority_ns: ns_targets_for(authorities, domain),
895                soa_zone: no_records
896                    .soa
897                    .as_ref()
898                    .map(|soa| normalize_ns_name(&soa.name.to_ascii()))
899                    .or_else(|| soa_zone_in(authorities)),
900            }
901        }
902        NetError::Dns(DnsError::ResponseCode(code)) => DirectNs::Rcode(code),
903        NetError::Timeout => DirectNs::Unreachable("timed out".to_string()),
904        // What `resolver.lookup()` actually returns when every transport
905        // attempt died at the local socket layer (verified against
906        // hickory-resolver 0.26): the pool's error fold (`most_specific`)
907        // always prefers the non-`Io` side and its accumulator starts as
908        // `NoConnections`, so all-`Io` failures surface as `NoConnections`.
909        // For these fresh single-server pools that can only mean local
910        // socket errors (no route / address family unavailable): hickory's
911        // UDP is unconnected (`send_to`/`recv_from`), so a *remote* refusal
912        // never yields `Io` on the UDP leg — a dark or refusing server
913        // times out instead, and `Timeout` outranks `NoConnections` in the
914        // fold. The server was therefore never reached: local, not lame.
915        NetError::NoConnections => DirectNs::LocalNoRoute(
916            "every connection attempt failed at the local socket layer".to_string(),
917        ),
918        // Defense in depth should hickory ever surface the socket error
919        // directly instead of folding it into `NoConnections`.
920        NetError::Io(io_err) if is_local_no_route(&io_err) => {
921            DirectNs::LocalNoRoute(io_err.to_string())
922        }
923        other => DirectNs::Unreachable(other.to_string()),
924    }
925}
926
927/// True when an io transport error means the packet never left this host:
928/// the kernel had no route to the destination (ENETUNREACH/EHOSTUNREACH), or
929/// cannot open a socket of its address family at all (EAFNOSUPPORT — e.g. an
930/// IPv6 server on a host whose kernel has IPv6 disabled). `TimedOut` never
931/// reaches here — `From<io::Error> for NetError` folds it into
932/// `NetError::Timeout` (which stays a lameness signal: packets were sent).
933pub(crate) fn is_local_no_route(err: &std::io::Error) -> bool {
934    use std::io::ErrorKind::{HostUnreachable, NetworkUnreachable};
935    matches!(err.kind(), NetworkUnreachable | HostUnreachable)
936        || err
937            .raw_os_error()
938            .is_some_and(|code| Some(code) == ADDRESS_FAMILY_UNSUPPORTED)
939}
940
941/// The OS error code for "address family not supported". std maps it to no
942/// `io::ErrorKind` of its own on any platform (it reads as uncategorized), so
943/// [`is_local_no_route`] matches the raw code: `EAFNOSUPPORT` on Unix, whose
944/// value differs across targets (97 on x86 and Arm Linux, 47 on macOS) —
945/// hence `libc` — and on Windows Winsock's `WSAEAFNOSUPPORT`, which is what
946/// std and tokio report for a failed socket call there (not the C runtime's
947/// `EAFNOSUPPORT`).
948#[cfg(unix)]
949pub(crate) const ADDRESS_FAMILY_UNSUPPORTED: Option<i32> = Some(libc::EAFNOSUPPORT);
950#[cfg(windows)]
951pub(crate) const ADDRESS_FAMILY_UNSUPPORTED: Option<i32> =
952    Some(windows_sys::Win32::Networking::WinSock::WSAEAFNOSUPPORT);
953#[cfg(not(any(unix, windows)))]
954pub(crate) const ADDRESS_FAMILY_UNSUPPORTED: Option<i32> = None;
955
956/// The parent zone of a normalized domain: everything after the leftmost
957/// label. `normalize_domain` guarantees at least one interior dot, so the
958/// fallback branch is unreachable in practice (kept panic-free regardless).
959fn parent_zone_of(domain: &str) -> String {
960    domain
961        .split_once('.')
962        .map(|(_, rest)| rest.to_string())
963        .unwrap_or_else(|| domain.to_string())
964}
965
966/// SSRF vetting for nameserver addresses, which come from untrusted DNS data
967/// (resolved NS names here, glue in the trace walker): splits `ips` into the
968/// addresses a direct query may be sent to and the refused ones, each with
969/// its [`crate::validation::describe_reserved_ip`] reason. `allow_private`
970/// is the `#[cfg(test)]` seam's flag and is always `false` in production.
971pub(crate) fn partition_reserved(
972    ips: &[IpAddr],
973    allow_private: bool,
974) -> (Vec<IpAddr>, Vec<(IpAddr, &'static str)>) {
975    let mut usable = Vec::new();
976    let mut refused = Vec::new();
977    for &ip in ips {
978        match crate::validation::describe_reserved_ip(&ip) {
979            Some(reason) if !allow_private => refused.push((ip, reason)),
980            _ => usable.push(ip),
981        }
982    }
983    (usable, refused)
984}
985
986/// Normalizes an NS host name for set comparison: lowercase, trailing dot
987/// stripped.
988fn normalize_ns_name(name: &str) -> String {
989    name.trim_end_matches('.').to_ascii_lowercase()
990}
991
992/// Extracts the normalized NS target names owned by `owner` from a record
993/// slice (answer or authority section). Records for other owners — e.g. the
994/// parent zone's own SOA in a NODATA response — are ignored.
995///
996/// Names are rendered with `to_ascii()`, never `to_string()`: hickory's
997/// `Display` decodes `xn--` labels to Unicode, so for every IDN the owner
998/// (`münchen.de.`) never equalled the punycode `owner` from
999/// `normalize_domain` (`xn--mnchen-3ya.de`). Every IDN domain came back with
1000/// an empty delegation ("not delegated"), and IDN TLDs hard-errored on an
1001/// empty parent NS set.
1002fn ns_targets_for(records: &[Record], owner: &str) -> Vec<String> {
1003    let owner = normalize_ns_name(owner);
1004    records
1005        .iter()
1006        .filter_map(|record| {
1007            if normalize_ns_name(&record.name.to_ascii()) != owner {
1008                return None;
1009            }
1010            match &record.data {
1011                HickoryRData::NS(ns) => Some(normalize_ns_name(&ns.0.to_ascii())),
1012                _ => None,
1013            }
1014        })
1015        .collect()
1016}
1017
1018/// The normalized owner of the first SOA record in `records` (an AUTHORITY
1019/// section), if any.
1020fn soa_zone_in(records: &[Record]) -> Option<String> {
1021    records
1022        .iter()
1023        .find(|record| matches!(record.data, HickoryRData::SOA(_)))
1024        .map(|record| normalize_ns_name(&record.name.to_ascii()))
1025}
1026
1027/// What one parent-zone server's reply says about `domain`'s delegation.
1028#[derive(Debug, PartialEq)]
1029enum ParentAnswer {
1030    /// The NS names it delegates (ANSWER and AUTHORITY, unioned).
1031    Delegation(Vec<String>),
1032    /// An authoritative "no delegation": NXDOMAIN or NODATA carrying the SOA
1033    /// of a zone that contains `domain`.
1034    Negative(ResponseCode),
1035    /// Not an answer for `domain` — the server is lame for the parent zone,
1036    /// so it does not count toward [`PARENT_SERVERS_WANTED`]. The reason,
1037    /// for the report's warnings.
1038    Lame(String),
1039}
1040
1041/// Reads a parent server's reply to `domain NS` (RD=0). A delegation is an
1042/// answer, and so is a negative one backed by the SOA of a zone containing
1043/// `domain` (the parent's own "no such delegation"; hickory's negative path
1044/// drops the AA bit, so the SOA is the evidence of authority). Anything else
1045/// — an empty reply without that SOA, a referral to some other zone — comes
1046/// from a server that does not serve the parent zone.
1047fn parent_answer(direct: DirectNs, domain: &str) -> ParentAnswer {
1048    match direct {
1049        DirectNs::Response {
1050            answer_ns,
1051            authority_ns,
1052            ..
1053        } if !answer_ns.is_empty() || !authority_ns.is_empty() => {
1054            ParentAnswer::Delegation(answer_ns.into_iter().chain(authority_ns).collect())
1055        }
1056        DirectNs::Response {
1057            response_code,
1058            soa_zone: Some(zone),
1059            ..
1060        } if zone_contains(&zone, domain) => ParentAnswer::Negative(response_code),
1061        DirectNs::Response { response_code, .. } => ParentAnswer::Lame(format!(
1062            "gave no delegation for {domain} ({response_code} without the zone's SOA)"
1063        )),
1064        DirectNs::Rcode(code) => ParentAnswer::Lame(format!("answered with {code}")),
1065        DirectNs::Unreachable(reason) => ParentAnswer::Lame(format!("did not respond: {reason}")),
1066        DirectNs::LocalNoRoute(reason) => {
1067            ParentAnswer::Lame(format!("not probed — no route from this host ({reason})"))
1068        }
1069    }
1070}
1071
1072/// True when `name` is `zone` or a name below it (both normalized).
1073fn zone_contains(zone: &str, name: &str) -> bool {
1074    zone.is_empty()
1075        || name == zone
1076        || name
1077            .strip_suffix(zone)
1078            .is_some_and(|rest| rest.ends_with('.'))
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    //! Hermetic tests: every DNS "server" in a scenario (the recursive
1084    //! resolver, the parent server, and each delegated NS) is a loopback UDP
1085    //! mock on its own ephemeral port, reached through the `#[cfg(test)]`
1086    //! seams (`with_recursive_upstream` / `with_port_map` /
1087    //! `allowing_private_hosts`). Scenarios script the shared
1088    //! [`crate::dns::test_support`] fixture, whose replies cover the AA bit,
1089    //! AUTHORITY-section referrals and error RCODEs; its `MockMode::Ignore`
1090    //! serves the timeout case.
1091
1092    use std::collections::HashMap;
1093    use std::net::Ipv4Addr;
1094
1095    use hickory_resolver::proto::rr::rdata as wire;
1096    use hickory_resolver::proto::rr::Name;
1097
1098    use super::*;
1099    use crate::dns::test_support::{spawn_mock_dns, spawn_mock_dns_fn, MockMode, MockReply};
1100
1101    // --- pure helpers --------------------------------------------------
1102
1103    #[test]
1104    fn parent_zone_strips_leftmost_label() {
1105        assert_eq!(parent_zone_of("example.com"), "com");
1106        assert_eq!(parent_zone_of("sub.example.co.uk"), "example.co.uk");
1107        assert_eq!(parent_zone_of("example.co.uk"), "co.uk");
1108    }
1109
1110    #[test]
1111    fn direct_opts_share_the_standard_set_and_disable_recursion() {
1112        // Regression: the hand-copied option set lacked the pinned
1113        // `UserProvidedOrder` server ordering.
1114        let mut opts = ResolverOpts::default();
1115        apply_direct_opts(&mut opts, Duration::from_secs(7));
1116        assert_eq!(
1117            opts.server_ordering_strategy,
1118            hickory_resolver::config::ServerOrderingStrategy::UserProvidedOrder
1119        );
1120        assert_eq!(opts.timeout, Duration::from_secs(7));
1121        assert!(!opts.recursion_desired);
1122    }
1123
1124    #[test]
1125    fn ns_targets_match_idn_owners_in_punycode() {
1126        // Regression: owners were compared via `Name`'s Display, which
1127        // renders `xn--` labels as Unicode, so this punycode owner (what
1128        // `normalize_domain("münchen.de")` produces) matched nothing.
1129        let owner = Name::from_ascii("xn--mnchen-3ya.de.").unwrap();
1130        let records = vec![
1131            Record::from_rdata(
1132                owner.clone(),
1133                300,
1134                HickoryRData::NS(wire::NS(
1135                    Name::from_ascii("ns1.xn--mnchen-3ya.de.").unwrap(),
1136                )),
1137            ),
1138            Record::from_rdata(
1139                owner,
1140                300,
1141                HickoryRData::NS(wire::NS(Name::from_ascii("NS2.Example.NET.").unwrap())),
1142            ),
1143        ];
1144        let mut targets = ns_targets_for(&records, "xn--mnchen-3ya.de");
1145        targets.sort();
1146        assert_eq!(
1147            targets,
1148            vec![
1149                "ns1.xn--mnchen-3ya.de".to_string(),
1150                "ns2.example.net".to_string()
1151            ]
1152        );
1153        // An IDN TLD (`рф` = `xn--p1ai`) as the owner, for the parent-zone
1154        // NS lookup path.
1155        let tld = vec![Record::from_rdata(
1156            Name::from_ascii("xn--p1ai.").unwrap(),
1157            300,
1158            HickoryRData::NS(wire::NS(Name::from_ascii("a.dns.ripn.net.").unwrap())),
1159        )];
1160        assert_eq!(
1161            ns_targets_for(&tld, "xn--p1ai"),
1162            vec!["a.dns.ripn.net".to_string()]
1163        );
1164    }
1165
1166    #[test]
1167    fn ns_name_normalization_is_case_and_dot_insensitive() {
1168        assert_eq!(normalize_ns_name("NS1.Example.COM."), "ns1.example.com");
1169        assert_eq!(normalize_ns_name("ns1.example.com"), "ns1.example.com");
1170    }
1171
1172    #[test]
1173    fn partition_reserved_refuses_reserved_addresses_with_their_reason() {
1174        let public: IpAddr = "192.5.6.30".parse().unwrap();
1175        let private: IpAddr = "10.0.0.53".parse().unwrap();
1176        let loopback: IpAddr = "::1".parse().unwrap();
1177        let (usable, refused) = partition_reserved(&[private, public, loopback], false);
1178        assert_eq!(usable, vec![public]);
1179        assert_eq!(
1180            refused,
1181            vec![
1182                (private, "private network (RFC 1918)"),
1183                (loopback, "IPv6 loopback (::1)")
1184            ]
1185        );
1186        // The test seam's flag lets everything through, in order.
1187        let (usable, refused) = partition_reserved(&[private, public], true);
1188        assert_eq!(usable, vec![private, public]);
1189        assert!(refused.is_empty());
1190    }
1191
1192    #[test]
1193    fn local_no_route_transport_errors_classify_as_local() {
1194        use std::io;
1195        // ENETUNREACH/EHOSTUNREACH mean the probe packet never left this
1196        // host (e.g. an IPv6-only NS probed from an IPv4-only host); hickory
1197        // surfaces them as `NetError::Io`. They say nothing about the
1198        // server's authority, so they must not classify as a remote failure.
1199        for kind in [
1200            io::ErrorKind::NetworkUnreachable,
1201            io::ErrorKind::HostUnreachable,
1202        ] {
1203            let direct = classify_net_error(io::Error::new(kind, "no route").into(), "example.com");
1204            assert!(
1205                matches!(direct, DirectNs::LocalNoRoute(_)),
1206                "{kind:?} classified as {direct:?}"
1207            );
1208        }
1209        // What `resolver.lookup()` actually surfaces for the all-local-
1210        // failure case: the pool folds per-attempt `Io` errors away
1211        // (`most_specific` always prefers the non-`Io` side, and the
1212        // accumulator starts as `NoConnections`), so a no-route probe comes
1213        // back as `NoConnections`, never `Io`. It must classify as local.
1214        let pooled = classify_net_error(NetError::NoConnections, "example.com");
1215        assert!(
1216            matches!(pooled, DirectNs::LocalNoRoute(_)),
1217            "NoConnections classified as {pooled:?}"
1218        );
1219        // A refused connection is the remote host answering: a genuine
1220        // remote signal that must keep reading as Unreachable (→ lame).
1221        let refused = classify_net_error(
1222            io::Error::new(io::ErrorKind::ConnectionRefused, "refused").into(),
1223            "example.com",
1224        );
1225        assert!(
1226            matches!(refused, DirectNs::Unreachable(_)),
1227            "refused classified as {refused:?}"
1228        );
1229    }
1230
1231    #[test]
1232    fn address_family_unsupported_classifies_as_local() {
1233        use std::io;
1234        // EAFNOSUPPORT (WSAEAFNOSUPPORT on Windows): no socket of the
1235        // server's address family can be opened on this host, e.g. an IPv6
1236        // server when the kernel has IPv6 disabled. Nothing was sent. std
1237        // gives the code no ErrorKind, so it is matched by the raw OS code,
1238        // which is how a failed socket call reports it.
1239        let code = ADDRESS_FAMILY_UNSUPPORTED.expect("a Unix or Windows target");
1240        let err = io::Error::from_raw_os_error(code);
1241        assert!(is_local_no_route(&err), "{err:?} ({:?})", err.kind());
1242        let direct = classify_net_error(err.into(), "example.com");
1243        assert!(
1244            matches!(direct, DirectNs::LocalNoRoute(_)),
1245            "EAFNOSUPPORT classified as {direct:?}"
1246        );
1247        // The code decides, not the wording or a nearby kind: an error with
1248        // no OS code, or an unsupported operation, stays a remote failure.
1249        for other in [
1250            io::Error::other("Address family not supported by protocol"),
1251            io::Error::from(io::ErrorKind::Unsupported),
1252        ] {
1253            assert!(!is_local_no_route(&other), "{other:?}");
1254        }
1255    }
1256
1257    /// Every lameness branch carries its own structured kind, so consumers
1258    /// (the security audit) never parse `reason`.
1259    #[test]
1260    fn each_lame_outcome_has_its_kind() {
1261        let response =
1262            |authoritative, code, answer: &[&str], authority: &[&str]| DirectNs::Response {
1263                authoritative,
1264                response_code: code,
1265                answer_ns: answer.iter().map(|s| s.to_string()).collect(),
1266                authority_ns: authority.iter().map(|s| s.to_string()).collect(),
1267                soa_zone: None,
1268            };
1269        let kind = |direct| match outcome_for_direct(direct) {
1270            ProbeOutcome::Lame(kind, _) => kind,
1271            other => panic!("expected lame, got {other:?}"),
1272        };
1273        assert_eq!(
1274            kind(response(true, ResponseCode::NXDomain, &[], &[])),
1275            LameKind::Nxdomain
1276        );
1277        assert_eq!(
1278            kind(response(true, ResponseCode::NoError, &[], &[])),
1279            LameKind::NoNsRecords
1280        );
1281        assert_eq!(
1282            kind(response(false, ResponseCode::NoError, &["ns1.test"], &[])),
1283            LameKind::NotAuthoritative
1284        );
1285        assert_eq!(
1286            kind(response(
1287                false,
1288                ResponseCode::NoError,
1289                &[],
1290                &["ns.parent.test"]
1291            )),
1292            LameKind::Referral
1293        );
1294        assert_eq!(
1295            kind(response(false, ResponseCode::NoError, &[], &[])),
1296            LameKind::EmptyAnswer
1297        );
1298        assert_eq!(
1299            kind(DirectNs::Rcode(ResponseCode::Refused)),
1300            LameKind::ErrorRcode
1301        );
1302        assert_eq!(
1303            kind(DirectNs::Unreachable("timed out".to_string())),
1304            LameKind::Unresponsive
1305        );
1306    }
1307
1308    #[test]
1309    fn local_no_route_probe_outcome_is_skipped_not_lame() {
1310        let outcome =
1311            outcome_for_direct(DirectNs::LocalNoRoute("Network is unreachable".to_string()));
1312        assert!(
1313            matches!(outcome, ProbeOutcome::Skipped(_)),
1314            "got {outcome:?}"
1315        );
1316    }
1317
1318    #[test]
1319    fn from_config_applies_dns_timeout() {
1320        let mut config = crate::config::SeerConfig::default();
1321        config.timeouts.dns_secs = 9;
1322        let checker = DelegationChecker::from_config(&config);
1323        assert_eq!(checker.timeout, Duration::from_secs(9));
1324    }
1325
1326    fn ns_rdata(target: &str) -> HickoryRData {
1327        HickoryRData::NS(wire::NS(
1328            Name::from_ascii(target).expect("valid test NS name"),
1329        ))
1330    }
1331
1332    fn loopback_a() -> HickoryRData {
1333        HickoryRData::A(wire::A(Ipv4Addr::LOCALHOST))
1334    }
1335
1336    /// Recursive-resolver mock for the standard scenario: parent zone `test`
1337    /// served by `a.parent.test`, and A records (→127.0.0.1) for every host
1338    /// in `resolvable`.
1339    async fn spawn_recursive_mock(resolvable: &[&str]) -> u16 {
1340        let resolvable: Vec<String> = resolvable.iter().map(|s| s.to_string()).collect();
1341        spawn_mock_dns_fn(move |qname, qtype| {
1342            match (qname, qtype) {
1343                ("test", HickoryRecordType::NS) => {
1344                    MockReply::Answer(vec![ns_rdata("a.parent.test.")])
1345                }
1346                (host, HickoryRecordType::A)
1347                    if host == "a.parent.test" || resolvable.iter().any(|r| r == host) =>
1348                {
1349                    MockReply::Answer(vec![loopback_a()])
1350                }
1351                // AAAA and unknown hosts: NODATA (lookup_ip falls back / fails).
1352                _ => MockReply::NoData,
1353            }
1354        })
1355        .await
1356    }
1357
1358    /// An authoritative zone server answering `domain NS` with `rrset`.
1359    async fn spawn_zone_server(rrset: &[&str]) -> u16 {
1360        let records: Vec<HickoryRData> = rrset.iter().map(|ns| ns_rdata(ns)).collect();
1361        spawn_mock_dns_fn(move |_qname, qtype| {
1362            if qtype == HickoryRecordType::NS {
1363                MockReply::AuthoritativeAnswer(records.clone())
1364            } else {
1365                MockReply::NoData
1366            }
1367        })
1368        .await
1369    }
1370
1371    /// A parent server that refers `seer.test` to `referral` (glueless).
1372    async fn spawn_parent_server(referral: &[&str]) -> u16 {
1373        let servers: Vec<(String, Vec<IpAddr>)> = referral
1374            .iter()
1375            .map(|ns| (ns.to_string(), Vec::new()))
1376            .collect();
1377        spawn_mock_dns_fn(move |qname, qtype| {
1378            if qname == "seer.test" && qtype == HickoryRecordType::NS {
1379                MockReply::Delegation {
1380                    zone: "seer.test".to_string(),
1381                    servers: servers.clone(),
1382                }
1383            } else {
1384                MockReply::NoData
1385            }
1386        })
1387        .await
1388    }
1389
1390    fn checker(recursive_port: u16, port_map: HashMap<String, u16>) -> DelegationChecker {
1391        DelegationChecker::new()
1392            .with_timeout(Duration::from_millis(500))
1393            .allowing_private_hosts()
1394            .with_recursive_upstream(Ipv4Addr::LOCALHOST.into(), recursive_port)
1395            .with_port_map(port_map)
1396    }
1397
1398    // --- end-to-end scenarios -------------------------------------------
1399
1400    #[tokio::test]
1401    async fn healthy_in_sync_delegation() {
1402        let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns2.seer.test"]).await;
1403        let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1404        let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1405        let ns2 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1406
1407        let report = checker(
1408            recursive,
1409            HashMap::from([
1410                ("a.parent.test".to_string(), parent),
1411                ("ns1.seer.test".to_string(), ns1),
1412                ("ns2.seer.test".to_string(), ns2),
1413            ]),
1414        )
1415        .check("seer.test")
1416        .await
1417        .expect("healthy delegation check must succeed");
1418
1419        assert_eq!(report.domain, "seer.test");
1420        assert_eq!(report.parent_zone, "test");
1421        assert_eq!(report.parent_server_queried, vec!["a.parent.test"]);
1422        assert_eq!(report.delegated_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
1423        assert_eq!(report.zone_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
1424        assert!(report.in_sync, "report should be in sync: {report:?}");
1425        assert!(report.missing_from_zone.is_empty());
1426        assert!(report.missing_from_parent.is_empty());
1427        assert!(report.lame.is_empty());
1428    }
1429
1430    #[tokio::test]
1431    async fn parent_lists_ns_the_zone_omits() {
1432        // Parent delegates to ns1 + ns3; both answer authoritatively but the
1433        // zone RRset only contains ns1 → ns3 is missing_from_zone.
1434        let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns3.seer.test"]).await;
1435        let parent = spawn_parent_server(&["ns1.seer.test.", "ns3.seer.test."]).await;
1436        let zone = spawn_zone_server(&["ns1.seer.test."]).await;
1437        let zone2 = spawn_zone_server(&["ns1.seer.test."]).await;
1438
1439        let report = checker(
1440            recursive,
1441            HashMap::from([
1442                ("a.parent.test".to_string(), parent),
1443                ("ns1.seer.test".to_string(), zone),
1444                ("ns3.seer.test".to_string(), zone2),
1445            ]),
1446        )
1447        .check("seer.test")
1448        .await
1449        .expect("check must succeed");
1450
1451        assert_eq!(report.delegated_ns, vec!["ns1.seer.test", "ns3.seer.test"]);
1452        assert_eq!(report.zone_ns, vec!["ns1.seer.test"]);
1453        assert_eq!(report.missing_from_zone, vec!["ns3.seer.test"]);
1454        assert!(report.missing_from_parent.is_empty());
1455        assert!(!report.in_sync);
1456        assert!(report.lame.is_empty(), "set mismatch is not lameness");
1457    }
1458
1459    #[tokio::test]
1460    async fn zone_lists_ns_the_parent_omits() {
1461        // Parent only delegates ns1, but the zone's RRset advertises ns1 +
1462        // ns2 → ns2 is missing_from_parent.
1463        let recursive = spawn_recursive_mock(&["ns1.seer.test"]).await;
1464        let parent = spawn_parent_server(&["ns1.seer.test."]).await;
1465        let zone = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1466
1467        let report = checker(
1468            recursive,
1469            HashMap::from([
1470                ("a.parent.test".to_string(), parent),
1471                ("ns1.seer.test".to_string(), zone),
1472            ]),
1473        )
1474        .check("seer.test")
1475        .await
1476        .expect("check must succeed");
1477
1478        assert_eq!(report.delegated_ns, vec!["ns1.seer.test"]);
1479        assert_eq!(report.zone_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
1480        assert_eq!(report.missing_from_parent, vec!["ns2.seer.test"]);
1481        assert!(report.missing_from_zone.is_empty());
1482        assert!(!report.in_sync);
1483    }
1484
1485    #[tokio::test]
1486    async fn refusing_server_is_lame() {
1487        let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns2.seer.test"]).await;
1488        let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1489        let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1490        let ns2 = spawn_mock_dns_fn(|_, _| MockReply::Rcode(ResponseCode::Refused)).await;
1491
1492        let report = checker(
1493            recursive,
1494            HashMap::from([
1495                ("a.parent.test".to_string(), parent),
1496                ("ns1.seer.test".to_string(), ns1),
1497                ("ns2.seer.test".to_string(), ns2),
1498            ]),
1499        )
1500        .check("seer.test")
1501        .await
1502        .expect("check must succeed");
1503
1504        // The sets still match (ns1 serves the full RRset), but the lame
1505        // server alone must break in_sync.
1506        assert!(report.missing_from_zone.is_empty());
1507        assert!(report.missing_from_parent.is_empty());
1508        assert_eq!(report.lame.len(), 1, "lame: {:?}", report.lame);
1509        assert_eq!(report.lame[0].host, "ns2.seer.test");
1510        assert!(
1511            report.lame[0].reason.to_lowercase().contains("refused"),
1512            "reason should mention the refusal: {}",
1513            report.lame[0].reason
1514        );
1515        assert_eq!(report.lame[0].kind, LameKind::ErrorRcode);
1516        assert!(!report.in_sync);
1517    }
1518
1519    #[tokio::test]
1520    async fn non_authoritative_answer_is_lame() {
1521        let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns2.seer.test"]).await;
1522        let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1523        let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1524        // ns2 answers the right RRset but without the AA bit — a cache, not
1525        // an authority.
1526        let ns2 = spawn_mock_dns_fn(|_, qtype| {
1527            if qtype == HickoryRecordType::NS {
1528                MockReply::Answer(vec![ns_rdata("ns1.seer.test."), ns_rdata("ns2.seer.test.")])
1529            } else {
1530                MockReply::NoData
1531            }
1532        })
1533        .await;
1534
1535        let report = checker(
1536            recursive,
1537            HashMap::from([
1538                ("a.parent.test".to_string(), parent),
1539                ("ns1.seer.test".to_string(), ns1),
1540                ("ns2.seer.test".to_string(), ns2),
1541            ]),
1542        )
1543        .check("seer.test")
1544        .await
1545        .expect("check must succeed");
1546
1547        assert_eq!(report.lame.len(), 1, "lame: {:?}", report.lame);
1548        assert_eq!(report.lame[0].host, "ns2.seer.test");
1549        assert!(
1550            report.lame[0].reason.contains("non-authoritatively"),
1551            "reason: {}",
1552            report.lame[0].reason
1553        );
1554        assert_eq!(report.lame[0].kind, LameKind::NotAuthoritative);
1555        assert!(!report.in_sync);
1556        // The healthy server still provides the zone view.
1557        assert_eq!(report.zone_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
1558    }
1559
1560    #[tokio::test]
1561    async fn unresponsive_server_is_lame_with_timeout_reason() {
1562        let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns2.seer.test"]).await;
1563        let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1564        let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1565        // Reuse the shared fixture's black-hole mode for the no-response case.
1566        let ns2 = spawn_mock_dns(MockMode::Ignore).await;
1567
1568        let report = checker(
1569            recursive,
1570            HashMap::from([
1571                ("a.parent.test".to_string(), parent),
1572                ("ns1.seer.test".to_string(), ns1),
1573                ("ns2.seer.test".to_string(), ns2),
1574            ]),
1575        )
1576        .check("seer.test")
1577        .await
1578        .expect("check must succeed");
1579
1580        assert_eq!(report.lame.len(), 1, "lame: {:?}", report.lame);
1581        assert_eq!(report.lame[0].host, "ns2.seer.test");
1582        assert!(
1583            report.lame[0].reason.contains("no usable response"),
1584            "reason: {}",
1585            report.lame[0].reason
1586        );
1587        assert_eq!(report.lame[0].kind, LameKind::Unresponsive);
1588        assert!(!report.in_sync);
1589    }
1590
1591    #[tokio::test]
1592    async fn unresolvable_glue_is_warned_and_skipped_not_lame() {
1593        // ns2 has no A record at the recursive resolver → skipped with a
1594        // warning; the remaining server still proves the sets match.
1595        let recursive = spawn_recursive_mock(&["ns1.seer.test"]).await;
1596        let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1597        let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1598
1599        let report = checker(
1600            recursive,
1601            HashMap::from([
1602                ("a.parent.test".to_string(), parent),
1603                ("ns1.seer.test".to_string(), ns1),
1604            ]),
1605        )
1606        .check("seer.test")
1607        .await
1608        .expect("check must succeed");
1609
1610        assert!(report.lame.is_empty(), "skip must not be lame: {report:?}");
1611        assert!(
1612            report.warnings.iter().any(|w| w.contains("ns2.seer.test")),
1613            "warnings should mention the skipped host: {:?}",
1614            report.warnings
1615        );
1616        assert!(report.in_sync, "sets match and nothing is lame: {report:?}");
1617    }
1618
1619    #[tokio::test]
1620    async fn no_responsive_parent_server_is_an_error() {
1621        // The parent host resolves but its server never answers.
1622        let recursive = spawn_recursive_mock(&[]).await;
1623        let parent = spawn_mock_dns(MockMode::Ignore).await;
1624
1625        let err = checker(
1626            recursive,
1627            HashMap::from([("a.parent.test".to_string(), parent)]),
1628        )
1629        .check("seer.test")
1630        .await
1631        .expect_err("no parent answer must be an error");
1632        assert!(
1633            matches!(err, SeerError::DnsError(_)),
1634            "expected DnsError, got: {err:?}"
1635        );
1636        assert!(err.to_string().contains("parent"), "got: {err}");
1637    }
1638
1639    /// Regression: each server was probed on one address only, so a server
1640    /// whose first (IPv4) address this host cannot route to — an IPv6-only
1641    /// host — was never reached. The next address is tried instead.
1642    #[tokio::test]
1643    async fn unroutable_address_falls_back_to_the_next() {
1644        let unroutable: IpAddr = Ipv4Addr::new(127, 0, 0, 2).into();
1645        let recursive = spawn_mock_dns_fn(move |qname, qtype| match (qname, qtype) {
1646            ("test", HickoryRecordType::NS) => MockReply::Answer(vec![ns_rdata("a.parent.test.")]),
1647            (_, HickoryRecordType::A) => MockReply::Answer(vec![
1648                HickoryRData::A(wire::A(Ipv4Addr::new(127, 0, 0, 2))),
1649                loopback_a(),
1650            ]),
1651            _ => MockReply::NoData,
1652        })
1653        .await;
1654        let parent = spawn_parent_server(&["ns1.seer.test."]).await;
1655        let ns1 = spawn_zone_server(&["ns1.seer.test."]).await;
1656        let ports = HashMap::from([
1657            ("a.parent.test".to_string(), parent),
1658            ("ns1.seer.test".to_string(), ns1),
1659        ]);
1660
1661        let report = checker(recursive, ports.clone())
1662            .with_unroutable(&[unroutable])
1663            .check("seer.test")
1664            .await
1665            .expect("check must succeed");
1666        assert_eq!(report.parent_server_queried, vec!["a.parent.test"]);
1667        assert_eq!(report.zone_ns, vec!["ns1.seer.test"]);
1668        assert!(report.in_sync, "{report:?}");
1669
1670        // With no routable address at all, the server is not reached.
1671        let err = checker(recursive, ports)
1672            .with_unroutable(&[unroutable, Ipv4Addr::LOCALHOST.into()])
1673            .check("seer.test")
1674            .await
1675            .expect_err("no parent server reachable");
1676        assert!(err.to_string().contains("parent"), "{err}");
1677    }
1678
1679    /// Regression: the check had no overall deadline, so silent servers
1680    /// held the caller for up to 17 timeouts. A probe still running at the
1681    /// deadline is reported as not probed, and the answers that arrived are
1682    /// kept.
1683    #[tokio::test]
1684    async fn the_check_is_held_to_one_deadline() {
1685        assert_eq!(CHECK_BUDGET_TIMEOUTS, 11);
1686        let recursive = spawn_recursive_mock(&["ns1.seer.test", "ns2.seer.test"]).await;
1687        let parent = spawn_parent_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1688        let ns1 = spawn_zone_server(&["ns1.seer.test.", "ns2.seer.test."]).await;
1689        let ns2 = spawn_mock_dns(MockMode::Ignore).await;
1690
1691        let started = std::time::Instant::now();
1692        let report = checker(
1693            recursive,
1694            HashMap::from([
1695                ("a.parent.test".to_string(), parent),
1696                ("ns1.seer.test".to_string(), ns1),
1697                ("ns2.seer.test".to_string(), ns2),
1698            ]),
1699        )
1700        .with_budget(Duration::from_millis(300))
1701        .check("seer.test")
1702        .await
1703        .expect("the parent answered in time");
1704
1705        // ns2 would time out after 500ms (twice); the deadline cut it short.
1706        assert!(started.elapsed() < Duration::from_millis(500));
1707        assert!(report.lame.is_empty(), "{report:?}");
1708        assert!(
1709            report
1710                .warnings
1711                .iter()
1712                .any(|w| w.contains("ns2.seer.test") && w.contains("budget")),
1713            "{:?}",
1714            report.warnings
1715        );
1716        assert_eq!(report.zone_ns, vec!["ns1.seer.test", "ns2.seer.test"]);
1717    }
1718
1719    // --- delegated_ns (lookup cross-check) ---------------------------------
1720
1721    #[tokio::test]
1722    async fn delegated_ns_reads_the_parent_referral_without_probing() {
1723        // No zone servers exist: delegated_ns must not need them.
1724        let recursive = spawn_recursive_mock(&[]).await;
1725        let parent = spawn_parent_server(&["NS2.seer.test.", "ns1.seer.test."]).await;
1726        let delegated = checker(
1727            recursive,
1728            HashMap::from([("a.parent.test".to_string(), parent)]),
1729        )
1730        .delegated_ns("seer.test")
1731        .await
1732        .expect("parent answers the delegation");
1733        assert_eq!(delegated, vec!["ns1.seer.test", "ns2.seer.test"]);
1734    }
1735
1736    #[tokio::test]
1737    async fn delegated_ns_errors_when_no_parent_server_answers() {
1738        let recursive = spawn_recursive_mock(&[]).await;
1739        // a.parent.test resolves, but nothing listens on its mapped port.
1740        let dead = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
1741        let port = dead.local_addr().unwrap().port();
1742        drop(dead);
1743        let result = checker(
1744            recursive,
1745            HashMap::from([("a.parent.test".to_string(), port)]),
1746        )
1747        .delegated_ns("seer.test")
1748        .await;
1749        assert!(result.is_err(), "{result:?}");
1750    }
1751
1752    #[test]
1753    fn dns_nameservers_compare_normalizes_and_diffs() {
1754        // NIC Togo (.tg): WHOIS lists 2 of the 4 delegated hosts.
1755        let whois = vec!["NS1.nic.tg.".to_string(), "ns2.nic.tg".to_string()];
1756        let delegated = ["ns1.nic.tg", "ns2.nic.tg", "ns4.nic.tg", "ns5.netmaster.tg"]
1757            .map(String::from)
1758            .to_vec();
1759        let ns = DnsNameservers::compare(&whois, delegated);
1760        assert!(!ns.in_sync);
1761        assert_eq!(
1762            ns.missing_from_whois,
1763            vec!["ns4.nic.tg", "ns5.netmaster.tg"]
1764        );
1765        assert!(ns.missing_from_dns.is_empty());
1766
1767        let same = DnsNameservers::compare(&whois, vec!["ns2.nic.tg".into(), "ns1.nic.tg".into()]);
1768        assert!(same.in_sync);
1769        assert_eq!(same.delegated, vec!["ns1.nic.tg", "ns2.nic.tg"]);
1770    }
1771
1772    // --- lame parent servers -------------------------------------------------
1773
1774    /// Recursive mock: parent zone `test` served by each of `parents`, and
1775    /// every A lookup → 127.0.0.1.
1776    async fn spawn_recursive_with_parents(parents: &'static [&'static str]) -> u16 {
1777        spawn_mock_dns_fn(move |qname, qtype| match (qname, qtype) {
1778            ("test", HickoryRecordType::NS) => {
1779                MockReply::Answer(parents.iter().map(|p| ns_rdata(&format!("{p}."))).collect())
1780            }
1781            (_, HickoryRecordType::A) => MockReply::Answer(vec![loopback_a()]),
1782            _ => MockReply::NoData,
1783        })
1784        .await
1785    }
1786
1787    /// Regression: any NOERROR reply counted toward `PARENT_SERVERS_WANTED`,
1788    /// so two lame parents (empty answer, no SOA) ended the hunt with an
1789    /// empty delegation before the healthy third was asked.
1790    #[tokio::test]
1791    async fn lame_parents_do_not_count_toward_wanted() {
1792        use std::sync::atomic::{AtomicUsize, Ordering};
1793        use std::sync::Arc;
1794
1795        let recursive =
1796            spawn_recursive_with_parents(&["a.parent.test", "b.parent.test", "c.parent.test"])
1797                .await;
1798        let lame_a = spawn_mock_dns_fn(|_, _| MockReply::NoData).await;
1799        // b refers the query to some other zone: no delegation for seer.test.
1800        let lame_b = spawn_mock_dns_fn(|_, _| MockReply::Delegation {
1801            zone: "elsewhere.test".to_string(),
1802            servers: vec![("ns.elsewhere.test".to_string(), Vec::new())],
1803        })
1804        .await;
1805        let asked = Arc::new(AtomicUsize::new(0));
1806        let seen = asked.clone();
1807        let healthy_c = spawn_mock_dns_fn(move |qname, qtype| {
1808            seen.fetch_add(1, Ordering::SeqCst);
1809            if qname == "seer.test" && qtype == HickoryRecordType::NS {
1810                MockReply::Delegation {
1811                    zone: "seer.test".to_string(),
1812                    servers: vec![("ns1.seer.test".to_string(), Vec::new())],
1813                }
1814            } else {
1815                MockReply::NoData
1816            }
1817        })
1818        .await;
1819        let checker = checker(
1820            recursive,
1821            HashMap::from([
1822                ("a.parent.test".to_string(), lame_a),
1823                ("b.parent.test".to_string(), lame_b),
1824                ("c.parent.test".to_string(), healthy_c),
1825            ]),
1826        );
1827
1828        let delegated = checker.delegated_ns("seer.test").await.expect("c answers");
1829        assert_eq!(delegated, vec!["ns1.seer.test"]);
1830        assert!(asked.load(Ordering::SeqCst) >= 1);
1831
1832        let ParentDelegation {
1833            servers_queried,
1834            warnings,
1835            ..
1836        } = checker
1837            .parent_delegation(
1838                "seer.test",
1839                "test",
1840                tokio::time::Instant::now() + Duration::from_secs(10),
1841                Duration::from_secs(10),
1842            )
1843            .await
1844            .expect("c answers");
1845        assert_eq!(servers_queried, vec!["c.parent.test"]);
1846        for lame in ["a.parent.test", "b.parent.test"] {
1847            assert!(
1848                warnings
1849                    .iter()
1850                    .any(|w| w.contains(lame) && w.contains("no delegation")),
1851                "{warnings:?}"
1852            );
1853        }
1854    }
1855
1856    /// The parent's own negative answer (NXDOMAIN with its SOA) is an
1857    /// answer: the server counts and the domain reads as not delegated.
1858    #[tokio::test]
1859    async fn parent_nxdomain_with_its_soa_counts_as_an_answer() {
1860        let recursive = spawn_recursive_mock(&[]).await;
1861        let parent = spawn_mock_dns_fn(|_, _| MockReply::AuthoritativeNxDomain("test")).await;
1862        let report = checker(
1863            recursive,
1864            HashMap::from([("a.parent.test".to_string(), parent)]),
1865        )
1866        .check("seer.test")
1867        .await
1868        .expect("the parent answered");
1869        assert_eq!(report.parent_server_queried, vec!["a.parent.test"]);
1870        assert!(report.delegated_ns.is_empty());
1871        assert!(
1872            report.warnings.iter().any(|w| w.contains("NXDOMAIN")),
1873            "{:?}",
1874            report.warnings
1875        );
1876    }
1877
1878    #[test]
1879    fn parent_answer_needs_a_delegation_or_the_zones_soa() {
1880        let response = |code, authority: &[&str], soa: Option<&str>| DirectNs::Response {
1881            authoritative: false,
1882            response_code: code,
1883            answer_ns: Vec::new(),
1884            authority_ns: authority.iter().map(|s| s.to_string()).collect(),
1885            soa_zone: soa.map(str::to_string),
1886        };
1887        let nx = ResponseCode::NXDomain;
1888        let ok = ResponseCode::NoError;
1889        assert_eq!(
1890            parent_answer(response(ok, &["ns1.seer.test"], None), "seer.test"),
1891            ParentAnswer::Delegation(vec!["ns1.seer.test".to_string()])
1892        );
1893        for soa in ["test", "", "seer.test"] {
1894            assert_eq!(
1895                parent_answer(response(nx, &[], Some(soa)), "seer.test"),
1896                ParentAnswer::Negative(nx),
1897                "{soa:?}"
1898            );
1899        }
1900        assert_eq!(
1901            parent_answer(response(ok, &[], Some("test")), "seer.test"),
1902            ParentAnswer::Negative(ok)
1903        );
1904        // No SOA, or the SOA of a zone that does not contain the name.
1905        for soa in [None, Some("other.test"), Some("eer.test")] {
1906            assert!(
1907                matches!(
1908                    parent_answer(response(ok, &[], soa), "seer.test"),
1909                    ParentAnswer::Lame(_)
1910                ),
1911                "{soa:?}"
1912            );
1913            assert!(matches!(
1914                parent_answer(response(nx, &[], soa), "seer.test"),
1915                ParentAnswer::Lame(_)
1916            ));
1917        }
1918        assert!(matches!(
1919            parent_answer(DirectNs::Rcode(ResponseCode::Refused), "seer.test"),
1920            ParentAnswer::Lame(_)
1921        ));
1922    }
1923}