Skip to main content

seer_core/dns/
query.rs

1//! Full DNS query results, as `dig` reports them.
2//!
3//! [`DnsResolver::query`](crate::dns::DnsResolver::query) returns a
4//! [`DnsQueryResult`]: the response code, the header flags, the ANSWER
5//! section with every record under its real owner name (so a CNAME chain
6//! reads as it does in dig), the AUTHORITY section as the server sent it,
7//! and a [`WildcardProbe`] telling whether the zone synthesizes answers for
8//! names that do not exist.
9//!
10//! [`DnsStatus`] is the response code in dig's vocabulary (`NOERROR`,
11//! `NXDOMAIN`, `SERVFAIL`, …), for a query result and for every hop of a
12//! [`DnsTrace`](crate::dns::DnsTrace). It is what separates "the name does
13//! not exist" (NXDOMAIN) from "the name exists but has no records of this
14//! type" (NOERROR without records of the type, i.e. NODATA:
15//! [`DnsQueryResult::is_nodata`]) — a distinction the record-list API
16//! ([`crate::dns::DnsResolver::resolve`]) folds away. Behind a CNAME chain
17//! in the ANSWER section, either one is about the chain's last target. A
18//! referral (NOERROR with no answer, only the NS records of a zone below)
19//! is neither: see [`DnsQueryResult::referral_zone`].
20//!
21//! The result is the response a server sent, section by section: the query
22//! goes straight to the upstream servers (`dns::transport`), not through a
23//! resolver lookup, so a CNAME chain that ends in NXDOMAIN or NODATA keeps
24//! its chain and every response keeps its header. The special-use names of
25//! RFC 6761 (`localhost`, `127.in-addr.arpa`, `invalid`, `onion`, …) are
26//! the exception: hickory's resolver answers them itself, without sending a
27//! query, and such a result says so ([`DnsQueryResult::answered_locally`])
28//! rather than passing off the resolver's answer as a server's.
29//!
30//! The pure steps of assembling a result — mapping a response (or the
31//! resolver's local answer) to an `Exchange`, ordering a CNAME chain first,
32//! merging the ANY fan-out, choosing and judging the wildcard probe — live
33//! here so they are unit-testable without a server; the resolver only runs
34//! the queries.
35
36use std::collections::HashSet;
37use std::fmt;
38use std::str::FromStr;
39use std::time::Duration;
40
41use hickory_resolver::lookup::Lookup;
42use hickory_resolver::net::{DnsError, NetError};
43use hickory_resolver::proto::op::{Message, MessageType, Metadata, ResponseCode};
44use hickory_resolver::proto::rr::domain::usage::{
45    ResolverUsage, INVALID, IN_ADDR_ARPA_127, IP6_ARPA_1, LOCAL, LOCALHOST, ONION,
46};
47use hickory_resolver::proto::rr::Name;
48use serde::{Deserialize, Deserializer, Serialize, Serializer};
49
50use super::records::{DnsRecord, RecordData, RecordType};
51use super::resolver::to_dns_record;
52use super::transport::NoResponse;
53use crate::error::{Result, SeerError};
54
55/// A DNS response code, named as `dig` prints it.
56///
57/// Serializes as that name (`"NOERROR"`, `"NXDOMAIN"`, …). Codes without a
58/// dedicated variant keep their numeric value in [`DnsStatus::Other`] and
59/// render as their standard mnemonic (`FORMERR`, `NOTIMP`, `BADVERS`, …), or
60/// `RCODE<n>` for an unassigned code.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum DnsStatus {
63    /// `NOERROR`: the query succeeded. Without records of the queried type
64    /// it is NODATA ([`DnsQueryResult::is_nodata`]) — behind a CNAME chain,
65    /// about the chain's last target — unless it is a referral
66    /// ([`DnsQueryResult::referral_zone`]).
67    NoError,
68    /// `NXDOMAIN`: the name does not exist — behind a CNAME chain, the
69    /// chain's last target (a dangling CNAME).
70    NxDomain,
71    /// `SERVFAIL`: the server could not answer (often a DNSSEC or upstream
72    /// failure).
73    ServFail,
74    /// `REFUSED`: the server declined to answer.
75    Refused,
76    /// Any other response code, by its numeric value.
77    Other(u16),
78}
79
80impl DnsStatus {
81    /// Whether the query succeeded (`NOERROR`), with or without answers.
82    pub fn is_success(self) -> bool {
83        self == DnsStatus::NoError
84    }
85
86    /// The numeric response code.
87    pub fn code(self) -> u16 {
88        match self {
89            DnsStatus::NoError => 0,
90            DnsStatus::ServFail => 2,
91            DnsStatus::NxDomain => 3,
92            DnsStatus::Refused => 5,
93            DnsStatus::Other(code) => code,
94        }
95    }
96
97    /// Builds the status for a numeric response code.
98    pub fn from_code(code: u16) -> Self {
99        match code {
100            0 => DnsStatus::NoError,
101            2 => DnsStatus::ServFail,
102            3 => DnsStatus::NxDomain,
103            5 => DnsStatus::Refused,
104            other => DnsStatus::Other(other),
105        }
106    }
107}
108
109/// Standard mnemonics for the codes without a dedicated variant (RFC 6895
110/// registry). Code 16 is both BADVERS and BADSIG; like dig, report BADVERS.
111const OTHER_MNEMONICS: &[(u16, &str)] = &[
112    (1, "FORMERR"),
113    (4, "NOTIMP"),
114    (6, "YXDOMAIN"),
115    (7, "YXRRSET"),
116    (8, "NXRRSET"),
117    (9, "NOTAUTH"),
118    (10, "NOTZONE"),
119    (16, "BADVERS"),
120    (17, "BADKEY"),
121    (18, "BADTIME"),
122    (19, "BADMODE"),
123    (20, "BADNAME"),
124    (21, "BADALG"),
125    (22, "BADTRUNC"),
126    (23, "BADCOOKIE"),
127];
128
129impl fmt::Display for DnsStatus {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            DnsStatus::NoError => f.write_str("NOERROR"),
133            DnsStatus::NxDomain => f.write_str("NXDOMAIN"),
134            DnsStatus::ServFail => f.write_str("SERVFAIL"),
135            DnsStatus::Refused => f.write_str("REFUSED"),
136            DnsStatus::Other(code) => match OTHER_MNEMONICS.iter().find(|(c, _)| c == code) {
137                Some((_, name)) => f.write_str(name),
138                None => write!(f, "RCODE{code}"),
139            },
140        }
141    }
142}
143
144impl FromStr for DnsStatus {
145    type Err = SeerError;
146
147    /// Parses the names [`Display`](fmt::Display) produces, case-insensitively.
148    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
149        let upper = s.trim().to_ascii_uppercase();
150        let status = match upper.as_str() {
151            "NOERROR" => DnsStatus::NoError,
152            "NXDOMAIN" => DnsStatus::NxDomain,
153            "SERVFAIL" => DnsStatus::ServFail,
154            "REFUSED" => DnsStatus::Refused,
155            other => {
156                if let Some((code, _)) = OTHER_MNEMONICS.iter().find(|(_, name)| *name == other) {
157                    DnsStatus::Other(*code)
158                } else if let Some(code) = other
159                    .strip_prefix("RCODE")
160                    .and_then(|n| n.parse::<u16>().ok())
161                {
162                    DnsStatus::from_code(code)
163                } else {
164                    return Err(SeerError::InvalidInput(format!("unknown DNS status: {s}")));
165                }
166            }
167        };
168        Ok(status)
169    }
170}
171
172impl From<ResponseCode> for DnsStatus {
173    fn from(code: ResponseCode) -> Self {
174        DnsStatus::from_code(u16::from(code))
175    }
176}
177
178impl Serialize for DnsStatus {
179    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
180        serializer.collect_str(self)
181    }
182}
183
184impl<'de> Deserialize<'de> for DnsStatus {
185    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
186        let s = String::deserialize(deserializer)?;
187        s.parse().map_err(serde::de::Error::custom)
188    }
189}
190
191/// A full DNS query result, the way `dig` reports it.
192///
193/// Built by [`DnsResolver::query`](crate::dns::DnsResolver::query). A negative
194/// answer is a result, not an error: NXDOMAIN, NODATA (NOERROR with no
195/// records of the type), SERVFAIL and REFUSED all come back as a
196/// `DnsQueryResult` with that [`status`](Self::status). An `Err` means
197/// there is no response to report: invalid input (a malformed name, a
198/// bare-domain SRV query), a nameserver that was refused (a private or
199/// reserved address) or did not resolve, or a transport failure (timeout,
200/// no connection).
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct DnsQueryResult {
203    /// The normalized name that was queried (for an IP-literal PTR query,
204    /// the reverse-DNS name).
205    pub name: String,
206    pub record_type: RecordType,
207    /// The nameserver spec the caller passed (as given), or None for the
208    /// default upstream (Google Public DNS) — and None when no server was
209    /// asked at all ([`answered_locally`](Self::answered_locally)).
210    pub server: Option<String>,
211    /// True when no query was sent: the name is under a special-use zone
212    /// (RFC 6761) that the resolver answers itself — `localhost`,
213    /// `127.in-addr.arpa` and `1.0…0.ip6.arpa` with loopback answers (NODATA
214    /// for other types), `invalid` and `onion` with NXDOMAIN. `server` is
215    /// then None, `flags` empty and `wildcard` None.
216    pub answered_locally: bool,
217    pub status: DnsStatus,
218    /// Header flags as dig prints them, lowercase, in dig's order: qr aa tc
219    /// rd ra ad cd — the ones the response header set, whatever its status.
220    ///
221    /// Empty only for an answer no server gave
222    /// ([`answered_locally`](Self::answered_locally)): seer never invents
223    /// flags, nor passes on the header hickory makes up for its own answer.
224    pub flags: Vec<String>,
225    /// The ANSWER section in order: the CNAME chain first, then the records
226    /// of the requested type — every record under its real owner name.
227    /// MX records are ordered by preference, as
228    /// [`resolve`](crate::dns::DnsResolver::resolve) orders them. For ANY:
229    /// all sub-query answers merged in the ANY type order, identical records
230    /// deduplicated.
231    ///
232    /// A negative answer lists its chain too: NXDOMAIN beside a CNAME chain
233    /// means the chain's last target does not exist (RFC 6604 §2), and
234    /// NODATA that the target has no records of the type — the queried name
235    /// exists, since it owns the first CNAME.
236    pub answers: Vec<DnsRecord>,
237    /// The AUTHORITY section as the server sent it: for a negative answer
238    /// the SOA of the zone that gave it, when the server sent one; for a
239    /// referral the NS records of the zone it refers to
240    /// ([`referral_zone`](Self::referral_zone)); beside an answer, whatever
241    /// the server added (an authoritative server may list its zone's NS
242    /// records). An ANY answer with records carries none (see
243    /// [`answers`](Self::answers)).
244    pub authority: Vec<DnsRecord>,
245    /// For ANY: the types whose sub-query failed — no server responded, or
246    /// it could not be sent — so the merged answer says nothing about them.
247    /// Empty for any other type and when every sub-query was answered
248    /// (omitted from the serialized form then).
249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
250    pub failed_types: Vec<FailedType>,
251    /// Wildcard probe outcome; None when the probe was not run or did not
252    /// complete.
253    pub wildcard: Option<WildcardProbe>,
254    /// Wall-clock time of the main query in milliseconds.
255    pub query_time_ms: u64,
256}
257
258impl DnsQueryResult {
259    /// NOERROR with no answer of the requested type (for ANY: no answers at
260    /// all) — the name exists, without records of the type. A referral is
261    /// not NODATA ([`referral_zone`](Self::referral_zone)).
262    pub fn is_nodata(&self) -> bool {
263        self.status == DnsStatus::NoError && self.lacks_answer() && self.referral_zone().is_none()
264    }
265
266    /// The zone this response refers the query to, when it is a referral
267    /// rather than an answer: NOERROR with no answer of the requested type,
268    /// the AA (authoritative) flag clear, NS records in AUTHORITY and no SOA
269    /// there (RFC 2308 §2.2) — the zone is the NS records' owner.
270    ///
271    /// A server that is neither authoritative for the name nor recursive (an
272    /// `@server` serving only a parent zone) answers this way, pointing at
273    /// the servers of the zone below it. Unlike NODATA, a referral says
274    /// nothing about whether the name exists. An authoritative response is
275    /// never a referral, even with its own zone's NS records in AUTHORITY
276    /// beside an unfollowed CNAME.
277    pub fn referral_zone(&self) -> Option<&str> {
278        if self.status != DnsStatus::NoError
279            || !self.lacks_answer()
280            || self.flags.iter().any(|flag| flag == "aa")
281            || self
282                .authority
283                .iter()
284                .any(|r| r.record_type == RecordType::SOA)
285        {
286            return None;
287        }
288        self.authority
289            .iter()
290            .find(|r| r.record_type == RecordType::NS)
291            .map(|r| r.name.as_str())
292    }
293
294    /// No answer of the requested type (for ANY: no answers at all).
295    fn lacks_answer(&self) -> bool {
296        match self.record_type {
297            RecordType::ANY => self.answers.is_empty(),
298            wanted => !self.records().any(|r| r.record_type == wanted),
299        }
300    }
301
302    /// The CNAME records at the front of `answers`.
303    pub fn cname_chain(&self) -> impl Iterator<Item = &DnsRecord> {
304        self.answers[..chain_len(self.record_type, &self.answers)].iter()
305    }
306
307    /// The answers that are not part of the CNAME chain (for a CNAME query:
308    /// the CNAME itself).
309    pub fn records(&self) -> impl Iterator<Item = &DnsRecord> {
310        self.answers[chain_len(self.record_type, &self.answers)..].iter()
311    }
312}
313
314/// A record type an `ANY` fan-out asked for without getting an answer
315/// ([`DnsQueryResult::failed_types`]).
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct FailedType {
318    pub record_type: RecordType,
319    /// Why, safe to show: each asked server's transport failure
320    /// (`8.8.8.8: timed out`), or a generic message.
321    pub error: String,
322}
323
324/// The outcome of the wildcard probe: whether the zone answers a random,
325/// certainly-unpublished sibling of the queried name.
326///
327/// A zone with a wildcard (`*.example.com`) answers every name under its
328/// parent that has no records of its own, so an answer equal to the probe's
329/// was likely synthesized from the wildcard rather than published for the
330/// name.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct WildcardProbe {
333    /// The random sibling name that was queried, e.g.
334    /// `seer-probe-3f9a1c2e7b.example.com`.
335    pub probe_name: String,
336    /// The probe name returned records of the queried type → a wildcard
337    /// answers under this parent.
338    pub present: bool,
339    /// The probe's final record set (the comparison-key set of its
340    /// [`records`](DnsQueryResult::records)) equals this answer's → the
341    /// answer is likely wildcard-synthesized (DNS alone cannot prove it
342    /// without DNSSEC).
343    pub matches_answer: bool,
344}
345
346/// How many leading `answers` form the CNAME chain. Zero for a CNAME query,
347/// whose CNAME is the answer itself rather than a hop towards it.
348fn chain_len(record_type: RecordType, answers: &[DnsRecord]) -> usize {
349    if record_type == RecordType::CNAME {
350        return 0;
351    }
352    answers
353        .iter()
354        .take_while(|r| r.record_type == RecordType::CNAME)
355        .count()
356}
357
358/// One lookup's outcome in seer's terms: the response code, header flags and
359/// the converted ANSWER/AUTHORITY sections. `DnsResolver::query` runs one per
360/// wire query (one per type for ANY) and assembles a [`DnsQueryResult`] from
361/// it.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub(crate) struct Exchange {
364    pub(crate) status: DnsStatus,
365    pub(crate) flags: Vec<String>,
366    pub(crate) answers: Vec<DnsRecord>,
367    pub(crate) authority: Vec<DnsRecord>,
368    /// An ANY merge's failed sub-queries ([`merge_any`]); empty otherwise.
369    pub(crate) failed_types: Vec<FailedType>,
370}
371
372impl Exchange {
373    /// Maps a response, exactly as the server sent it, to an exchange: the
374    /// status from the header's response code — extended by EDNS, which
375    /// hickory merges in as it decodes (BADVERS, BADCOOKIE, …) — the flags
376    /// from the header, and the converted ANSWER and AUTHORITY sections.
377    /// Whatever the response code, NXDOMAIN, SERVFAIL and REFUSED included,
378    /// a response is an outcome to report.
379    pub(crate) fn from_message(message: &Message) -> Self {
380        Self {
381            status: message.metadata.response_code.into(),
382            flags: header_flags(&message.metadata),
383            answers: message.answers.iter().filter_map(to_dns_record).collect(),
384            authority: message
385                .authorities
386                .iter()
387                .filter_map(to_dns_record)
388                .collect(),
389            failed_types: Vec::new(),
390        }
391    }
392
393    /// Maps the answer hickory's resolver gives itself for a special-use
394    /// name ([`answered_locally`]): its loopback records (A, AAAA, PTR), or
395    /// a negative answer — NODATA for other types, NXDOMAIN for `invalid`
396    /// and `onion` — that it reports as an error carrying the code. Its
397    /// header is its own; [`into_result`](Self::into_result) drops it.
398    pub(crate) fn from_local(
399        result: std::result::Result<Lookup, NetError>,
400        record_type: RecordType,
401    ) -> Result<Self> {
402        match result {
403            Ok(lookup) => Ok(Self::from_message(lookup.message())),
404            Err(NetError::Dns(DnsError::NoRecordsFound(no_records))) => Ok(Self {
405                status: no_records.response_code.into(),
406                flags: Vec::new(),
407                answers: Vec::new(),
408                authority: Vec::new(),
409                failed_types: Vec::new(),
410            }),
411            Err(e) => Err(SeerError::DnsError(format!(
412                "{record_type} lookup failed: {e}"
413            ))),
414        }
415    }
416
417    /// The exchange with its answers in report order (see
418    /// [`order_answers`]).
419    pub(crate) fn ordered(mut self, query_name: &str, record_type: RecordType) -> Self {
420        self.answers = order_answers(query_name, record_type, self.answers);
421        self
422    }
423
424    /// The answers past the CNAME chain.
425    pub(crate) fn records(&self, record_type: RecordType) -> &[DnsRecord] {
426        &self.answers[chain_len(record_type, &self.answers)..]
427    }
428
429    /// NOERROR with records past the CNAME chain — the only kind of answer a
430    /// wildcard probe is judged against.
431    pub(crate) fn has_records(&self, record_type: RecordType) -> bool {
432        self.status == DnsStatus::NoError && !self.records(record_type).is_empty()
433    }
434
435    /// Assembles the public result. For a name the resolver answers itself
436    /// ([`answered_locally`]) no server was asked and the header is
437    /// hickory's own, so neither is reported.
438    pub(crate) fn into_result(
439        self,
440        name: String,
441        record_type: RecordType,
442        server: Option<&str>,
443        wildcard: Option<WildcardProbe>,
444        query_time: Duration,
445    ) -> DnsQueryResult {
446        let local = answered_locally(&name);
447        DnsQueryResult {
448            name,
449            record_type,
450            server: server.filter(|_| !local).map(str::to_string),
451            answered_locally: local,
452            status: self.status,
453            flags: if local { Vec::new() } else { self.flags },
454            answers: self.answers,
455            authority: self.authority,
456            failed_types: self.failed_types,
457            wildcard,
458            query_time_ms: duration_ms(query_time),
459        }
460    }
461}
462
463/// Whole milliseconds in `elapsed`, saturating at `u64::MAX`: the
464/// `query_time_ms` of a query result and of a trace hop.
465pub(crate) fn duration_ms(elapsed: Duration) -> u64 {
466    u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
467}
468
469/// Renders the header flags as dig prints them: lowercase, in dig's order
470/// (qr aa tc rd ra ad cd), only the ones that are set.
471pub(crate) fn header_flags(metadata: &Metadata) -> Vec<String> {
472    [
473        (metadata.message_type == MessageType::Response, "qr"),
474        (metadata.authoritative, "aa"),
475        (metadata.truncation, "tc"),
476        (metadata.recursion_desired, "rd"),
477        (metadata.recursion_available, "ra"),
478        (metadata.authentic_data, "ad"),
479        (metadata.checking_disabled, "cd"),
480    ]
481    .into_iter()
482    .filter(|(set, _)| *set)
483    .map(|(_, flag)| flag.to_string())
484    .collect()
485}
486
487/// A reported DNS name in one comparable spelling: lowercase (RFC 4343),
488/// without the trailing root dot. Every name in a result is already ASCII
489/// (A-labels, see `to_dns_record`), but owner and query names are reported
490/// without the dot and RDATA names such as a CNAME target with it.
491fn name_key(name: &str) -> String {
492    name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase()
493}
494
495/// Orders an ANSWER section for reporting: the CNAME chain that starts at
496/// `query_name` first, hop by hop, then everything else in the server's
497/// order (MX by preference, matching `resolve`). Names compare in one
498/// spelling ([`name_key`]). A CNAME query's answer is left as is.
499pub(crate) fn order_answers(
500    query_name: &str,
501    record_type: RecordType,
502    answers: Vec<DnsRecord>,
503) -> Vec<DnsRecord> {
504    if record_type == RecordType::CNAME {
505        return answers;
506    }
507    let mut rest = answers;
508    let mut ordered = Vec::with_capacity(rest.len());
509    let mut current = name_key(query_name);
510    // Each hop removes a record, so even a looping chain terminates.
511    while let Some(pos) = rest
512        .iter()
513        .position(|r| r.record_type == RecordType::CNAME && name_key(&r.name) == current)
514    {
515        let hop = rest.remove(pos);
516        if let RecordData::CNAME { target } = &hop.data {
517            current = name_key(target);
518        }
519        ordered.push(hop);
520    }
521    if record_type == RecordType::MX {
522        rest.sort_by_key(|r| match &r.data {
523            RecordData::MX { preference, .. } => *preference,
524            _ => 0,
525        });
526    }
527    ordered.extend(rest);
528    ordered
529}
530
531/// Drops repeats of the same resource record, keeping the first. Two records
532/// are the same RR when owner (case-insensitively), type and data
533/// ([`RecordData::comparison_key`]) match — the TTL is ignored, because the
534/// ANY fan-out's sub-queries can read one cached record a second apart.
535pub(crate) fn dedupe_records(records: impl IntoIterator<Item = DnsRecord>) -> Vec<DnsRecord> {
536    let mut seen = HashSet::new();
537    records
538        .into_iter()
539        .filter(|r| {
540            seen.insert((
541                r.name.to_ascii_lowercase(),
542                r.record_type,
543                r.data.comparison_key(),
544            ))
545        })
546        .collect()
547}
548
549/// Merges the ANY fan-out's sub-query outcomes (in ANY type order) into one
550/// exchange:
551///
552/// - status: NOERROR if any sub-query got NOERROR, NXDOMAIN if all got
553///   NXDOMAIN, else the first sub-query's status;
554/// - flags: the first sub-query's that got a response;
555/// - answers: concatenated in order, repeats removed ([`dedupe_records`]) —
556///   a CNAME'd name returns its CNAME to every sub-query;
557/// - authority: only for a negative merge (no answers), and only from the
558///   sub-queries that returned the merged status, deduplicated the same way.
559///   A positive answer carries none: the sub-queries that came back NODATA
560///   each bring their zone's SOA — the DS one, answered by the parent, the
561///   parent's — and none of those describes the answer.
562///
563/// - failed_types: the sub-queries that got no response (or could not be
564///   sent), each with its reason — so a partial merge does not pass for a
565///   complete one.
566///
567/// When every sub-query failed, the last error is returned ([`fold_any`]).
568pub(crate) fn merge_any(results: Vec<(RecordType, SubQuery)>) -> Result<Exchange> {
569    let (exchanges, failures) = fold_any(results.into_iter().map(|(record_type, result)| {
570        let result = match result {
571            Ok(Ok(exchange)) => Ok(exchange),
572            // The per-server reasons are what a caller's own `Silent` reply
573            // already shows; nothing internal.
574            Ok(Err(why)) => Err((
575                why.to_string(),
576                SeerError::DnsError(format!("{record_type} lookup failed: {why}")),
577            )),
578            Err(e) => Err((e.sanitized_message(), e)),
579        };
580        (record_type, result)
581    }))
582    .map_err(|(_, e)| e)?;
583    let failed_types = failures
584        .into_iter()
585        .map(|(record_type, (error, _))| FailedType { record_type, error })
586        .collect();
587    let Some(first) = exchanges.first() else {
588        return Err(SeerError::DnsError("ANY lookup ran no queries".to_string()));
589    };
590    let status = if exchanges.iter().any(|e| e.status == DnsStatus::NoError) {
591        DnsStatus::NoError
592    } else if exchanges.iter().all(|e| e.status == DnsStatus::NxDomain) {
593        DnsStatus::NxDomain
594    } else {
595        first.status
596    };
597    let flags = first.flags.clone();
598    let answers = dedupe_records(exchanges.iter().flat_map(|e| e.answers.iter().cloned()));
599    let authority = if answers.is_empty() {
600        dedupe_records(
601            exchanges
602                .iter()
603                .filter(|e| e.status == status)
604                .flat_map(|e| e.authority.iter().cloned()),
605        )
606    } else {
607        Vec::new()
608    };
609    Ok(Exchange {
610        status,
611        flags,
612        answers,
613        authority,
614        failed_types,
615    })
616}
617
618/// One ANY sub-query's outcome: an exchange, no response from any server
619/// (the inner `Err`), or an error before one could be sent.
620pub(crate) type SubQuery = Result<std::result::Result<Exchange, NoResponse>>;
621
622/// [`fold_any`]'s split: the successes, and the failures by type.
623pub(crate) type Folded<T, E> = (Vec<T>, Vec<(RecordType, E)>);
624
625/// The ANY fan-out's fold, shared by `DnsResolver::resolve` and
626/// [`merge_any`]: the sub-queries that succeeded, in order, and the failed
627/// ones by type — or, when none succeeded, the last failure, rather than an
628/// empty set that would read as "no records".
629pub(crate) fn fold_any<T, E>(
630    results: impl IntoIterator<Item = (RecordType, std::result::Result<T, E>)>,
631) -> std::result::Result<Folded<T, E>, E> {
632    let mut succeeded = Vec::new();
633    let mut failed = Vec::new();
634    for (record_type, result) in results {
635        match result {
636            Ok(value) => succeeded.push(value),
637            Err(e) => failed.push((record_type, e)),
638        }
639    }
640    if succeeded.is_empty() {
641        if let Some((_, e)) = failed.pop() {
642            return Err(e);
643        }
644    }
645    Ok((succeeded, failed))
646}
647
648/// Whether hickory's resolver answers `name` itself, without sending a
649/// query: its caching client handles the special-use zones of RFC 6761
650/// locally — `localhost`, `127.in-addr.arpa` and `1.0…0.ip6.arpa` get
651/// loopback answers (NODATA for other types), `invalid` and `onion`
652/// NXDOMAIN. The zones and their treatment are hickory's own, consulted as
653/// its `CachingClient` consults them (`local` among them, whose queries it
654/// sends on).
655pub(crate) fn answered_locally(name: &str) -> bool {
656    let Ok(name) = Name::from_ascii(name) else {
657        return false;
658    };
659    [
660        &LOCALHOST,
661        &IN_ADDR_ARPA_127,
662        &IP6_ARPA_1,
663        &INVALID,
664        &LOCAL,
665        &ONION,
666    ]
667    .into_iter()
668    .find(|zone| zone.name().zone_of(&name))
669    .is_some_and(|zone| {
670        matches!(
671            zone.resolver(),
672            ResolverUsage::Loopback | ResolverUsage::NxDomain
673        )
674    })
675}
676
677/// Leftmost label of every wildcard probe name, before its random hex.
678const PROBE_LABEL_PREFIX: &str = "seer-probe-";
679
680/// A fresh random probe label, e.g. `seer-probe-3f9a1c2e7b`: 40 random bits
681/// as 10 lowercase hex digits, so the name is certainly not published.
682pub(crate) fn random_probe_label() -> String {
683    use rand::RngExt;
684    let bits = rand::rng().random::<u64>() & 0xFF_FFFF_FFFF;
685    format!("{PROBE_LABEL_PREFIX}{bits:010x}")
686}
687
688/// The wildcard probe name for a query: `label` in place of the leftmost
689/// label of `name` — a sibling under the same parent. For an
690/// `_service._proto` style name too, only the leftmost label is replaced.
691///
692/// `None` when a probe is meaningless or out of bounds: for ANY (a fan-out,
693/// not one answer), for a wildcard name itself (`*.example.com`), for a
694/// name that is not strictly below its registrable domain — a sibling of
695/// `example.com` or `example.co.uk` would sit directly under a public
696/// suffix, and seer never probes at TLD level — and for a name the resolver
697/// answers itself ([`answered_locally`]), whose sibling it answers alike.
698pub(crate) fn wildcard_probe_name(
699    name: &str,
700    record_type: RecordType,
701    label: &str,
702) -> Option<String> {
703    if record_type == RecordType::ANY
704        || name == "*"
705        || name.starts_with("*.")
706        || answered_locally(name)
707    {
708        return None;
709    }
710    crate::psl::registrable_parent(name)?;
711    let (_, parent) = name.split_once('.')?;
712    Some(format!("{label}.{parent}"))
713}
714
715/// Whether two answers carry the same record set, ignoring owner names and
716/// TTLs: their (type, [`RecordData::comparison_key`]) sets are equal.
717pub(crate) fn same_record_set(a: &[DnsRecord], b: &[DnsRecord]) -> bool {
718    let key_set = |records: &[DnsRecord]| {
719        records
720            .iter()
721            .map(|r| (r.record_type, r.data.comparison_key()))
722            .collect::<HashSet<_>>()
723    };
724    key_set(a) == key_set(b)
725}
726
727/// Judges the wildcard probe against the main answer.
728///
729/// `None` — nothing to report — unless the main answer is NOERROR with
730/// records and the probe completed: a transport error or a SERVFAIL-style
731/// status says nothing about a wildcard. A probe NXDOMAIN means no wildcard
732/// answers there; a probe NOERROR with records of the queried type means one
733/// does, and its record set is compared with the main answer's.
734pub(crate) fn wildcard_outcome(
735    probe_name: &str,
736    record_type: RecordType,
737    answer: &Exchange,
738    probe: Result<Exchange>,
739) -> Option<WildcardProbe> {
740    if !answer.has_records(record_type) {
741        return None;
742    }
743    let answered = answer.records(record_type);
744    let probe = probe.ok()?;
745    let probed = probe.records(record_type);
746    let present = match probe.status {
747        DnsStatus::NoError => probed.iter().any(|r| r.record_type == record_type),
748        DnsStatus::NxDomain => false,
749        _ => return None,
750    };
751    Some(WildcardProbe {
752        probe_name: probe_name.to_string(),
753        present,
754        matches_answer: present && same_record_set(answered, probed),
755    })
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn status_names_match_dig() {
764        assert_eq!(DnsStatus::NoError.to_string(), "NOERROR");
765        assert_eq!(DnsStatus::NxDomain.to_string(), "NXDOMAIN");
766        assert_eq!(DnsStatus::ServFail.to_string(), "SERVFAIL");
767        assert_eq!(DnsStatus::Refused.to_string(), "REFUSED");
768        assert_eq!(DnsStatus::Other(1).to_string(), "FORMERR");
769        assert_eq!(DnsStatus::Other(4).to_string(), "NOTIMP");
770        assert_eq!(DnsStatus::Other(16).to_string(), "BADVERS");
771        assert_eq!(DnsStatus::Other(3000).to_string(), "RCODE3000");
772    }
773
774    #[test]
775    fn status_maps_every_hickory_code_by_value() {
776        assert_eq!(DnsStatus::from(ResponseCode::NoError), DnsStatus::NoError);
777        assert_eq!(DnsStatus::from(ResponseCode::NXDomain), DnsStatus::NxDomain);
778        assert_eq!(DnsStatus::from(ResponseCode::ServFail), DnsStatus::ServFail);
779        assert_eq!(DnsStatus::from(ResponseCode::Refused), DnsStatus::Refused);
780        assert_eq!(DnsStatus::from(ResponseCode::NotImp), DnsStatus::Other(4));
781        assert_eq!(
782            DnsStatus::from(ResponseCode::Unknown(3000)),
783            DnsStatus::Other(3000)
784        );
785    }
786
787    #[test]
788    fn status_round_trips_through_its_name() {
789        for code in [0u16, 1, 2, 3, 4, 5, 6, 9, 16, 23, 3000] {
790            let status = DnsStatus::from_code(code);
791            assert_eq!(status.to_string().parse::<DnsStatus>().unwrap(), status);
792            assert_eq!(status.code(), code);
793        }
794        assert_eq!(
795            "nxdomain".parse::<DnsStatus>().unwrap(),
796            DnsStatus::NxDomain
797        );
798        assert!("NOPE".parse::<DnsStatus>().is_err());
799    }
800
801    #[test]
802    fn status_serializes_as_its_name() {
803        assert_eq!(
804            serde_json::to_string(&DnsStatus::NxDomain).unwrap(),
805            "\"NXDOMAIN\""
806        );
807        assert_eq!(
808            serde_json::from_str::<DnsStatus>("\"SERVFAIL\"").unwrap(),
809            DnsStatus::ServFail
810        );
811    }
812
813    // --- Pure result-assembly pieces -----------------------------------
814
815    use hickory_resolver::net::NoRecords;
816    use hickory_resolver::proto::op::{OpCode, Query};
817    use hickory_resolver::proto::rr::rdata::SOA;
818    use hickory_resolver::proto::rr::{Name, RData, Record, RecordType as WireType};
819
820    fn rec(name: &str, record_type: RecordType, ttl: u32, data: RecordData) -> DnsRecord {
821        DnsRecord {
822            name: name.to_string(),
823            record_type,
824            ttl,
825            data,
826        }
827    }
828
829    fn a(name: &str, address: &str) -> DnsRecord {
830        rec(
831            name,
832            RecordType::A,
833            300,
834            RecordData::A {
835                address: address.to_string(),
836            },
837        )
838    }
839
840    fn cname(name: &str, target: &str) -> DnsRecord {
841        rec(
842            name,
843            RecordType::CNAME,
844            300,
845            RecordData::CNAME {
846                target: target.to_string(),
847            },
848        )
849    }
850
851    fn mx(name: &str, preference: u16) -> DnsRecord {
852        rec(
853            name,
854            RecordType::MX,
855            300,
856            RecordData::MX {
857                preference,
858                exchange: format!("mx{preference}.seer.test."),
859            },
860        )
861    }
862
863    fn exchange(status: DnsStatus, answers: Vec<DnsRecord>) -> Exchange {
864        Exchange {
865            status,
866            flags: vec!["qr".to_string(), "rd".to_string(), "ra".to_string()],
867            answers,
868            authority: vec![],
869            failed_types: vec![],
870        }
871    }
872
873    /// An answerless response with `status` and `authority`.
874    fn negative(status: DnsStatus, authority: Vec<DnsRecord>) -> Exchange {
875        Exchange {
876            authority,
877            ..exchange(status, vec![])
878        }
879    }
880
881    fn soa_record(zone: &str) -> Record<SOA> {
882        Record::from_rdata(
883            Name::from_ascii(format!("{zone}.")).unwrap(),
884            900,
885            SOA::new(
886                Name::from_ascii(format!("ns1.{zone}.")).unwrap(),
887                Name::from_ascii(format!("hostmaster.{zone}.")).unwrap(),
888                7,
889                7200,
890                3600,
891                1209600,
892                300,
893            ),
894        )
895    }
896
897    fn query_for(name: &str) -> Query {
898        Query::query(Name::from_ascii(name).unwrap(), WireType::A)
899    }
900
901    #[test]
902    fn header_flags_follow_digs_order() {
903        let mut metadata = Metadata::new(7, MessageType::Response, OpCode::Query);
904        metadata.recursion_desired = true;
905        metadata.recursion_available = true;
906        assert_eq!(header_flags(&metadata), ["qr", "rd", "ra"]);
907
908        metadata.authoritative = true;
909        metadata.truncation = true;
910        metadata.authentic_data = true;
911        metadata.checking_disabled = true;
912        assert_eq!(
913            header_flags(&metadata),
914            ["qr", "aa", "tc", "rd", "ra", "ad", "cd"]
915        );
916
917        // A query message has no `qr`; nothing is invented.
918        let query = Metadata::new(7, MessageType::Query, OpCode::Query);
919        assert!(header_flags(&query).is_empty());
920    }
921
922    /// A response to `name`/A as a server sends it, with `code` and the
923    /// header flags qr rd ra.
924    fn response(name: &str, code: ResponseCode) -> Message {
925        let mut message = Message::response(7, OpCode::Query);
926        message.metadata.response_code = code;
927        message.metadata.recursion_desired = true;
928        message.metadata.recursion_available = true;
929        message.add_query(query_for(name));
930        message
931    }
932
933    fn wire_record(owner: &str, data: RData) -> Record {
934        Record::from_rdata(Name::from_ascii(owner).unwrap(), 300, data)
935    }
936
937    fn wire_cname(owner: &str, target: &str) -> Record {
938        wire_record(
939            owner,
940            RData::CNAME(hickory_resolver::proto::rr::rdata::CNAME(
941                Name::from_ascii(target).unwrap(),
942            )),
943        )
944    }
945
946    #[test]
947    fn from_message_reads_status_flags_and_sections_as_sent() {
948        let mut message = response("www.seer.test.", ResponseCode::NoError);
949        message.add_answer(wire_record(
950            "www.seer.test.",
951            RData::A(hickory_resolver::proto::rr::rdata::A::new(192, 0, 2, 1)),
952        ));
953        let exchange = Exchange::from_message(&message);
954        assert_eq!(exchange.status, DnsStatus::NoError);
955        assert_eq!(exchange.flags, ["qr", "rd", "ra"]);
956        assert_eq!(exchange.answers, [a("www.seer.test", "192.0.2.1")]);
957        assert!(exchange.authority.is_empty());
958    }
959
960    #[test]
961    fn from_message_keeps_the_chain_and_header_of_a_negative_answer() {
962        // Regression: a CNAME whose target does not exist came back as a
963        // bare NXDOMAIN — no chain, no flags — because hickory's resolver
964        // chased the target itself and turned its negative answer into an
965        // error. The response as sent keeps all three sections.
966        let mut dangling = response("www.seer.test.", ResponseCode::NXDomain);
967        dangling.add_answer(wire_cname("www.seer.test.", "gone.cdn.test."));
968        dangling.add_authority(soa_record("cdn.test").into_record_of_rdata());
969        let exchange = Exchange::from_message(&dangling);
970        assert_eq!(exchange.status, DnsStatus::NxDomain);
971        assert_eq!(exchange.flags, ["qr", "rd", "ra"]);
972        assert_eq!(exchange.answers, [cname("www.seer.test", "gone.cdn.test.")]);
973        assert_eq!(exchange.authority.len(), 1);
974        assert_eq!(exchange.authority[0].name, "cdn.test");
975        assert_eq!(exchange.authority[0].record_type, RecordType::SOA);
976
977        // An error code is a response too, header and all.
978        for (code, status) in [
979            (ResponseCode::ServFail, DnsStatus::ServFail),
980            (ResponseCode::Refused, DnsStatus::Refused),
981            (ResponseCode::NotImp, DnsStatus::Other(4)),
982            (ResponseCode::BADVERS, DnsStatus::Other(16)),
983        ] {
984            let exchange = Exchange::from_message(&response("www.seer.test.", code));
985            assert_eq!(exchange, negative(status, vec![]), "{code}");
986        }
987    }
988
989    #[test]
990    fn from_local_maps_the_resolvers_own_answers() {
991        // Loopback records for an address or PTR query.
992        let answer = wire_record(
993            "localhost.",
994            RData::A(hickory_resolver::proto::rr::rdata::A::new(127, 0, 0, 1)),
995        );
996        let lookup = Lookup::new_with_max_ttl(query_for("localhost."), [answer]);
997        let exchange = Exchange::from_local(Ok(lookup), RecordType::A).unwrap();
998        assert_eq!(exchange.status, DnsStatus::NoError);
999        assert_eq!(exchange.answers, [a("localhost", "127.0.0.1")]);
1000
1001        // NODATA for other types, NXDOMAIN for `invalid` and `onion`: the
1002        // code alone, with no header and no records.
1003        for (code, status) in [
1004            (ResponseCode::NoError, DnsStatus::NoError),
1005            (ResponseCode::NXDomain, DnsStatus::NxDomain),
1006        ] {
1007            let no_records = NoRecords::new(query_for("x.onion."), code);
1008            let exchange =
1009                Exchange::from_local(Err(NetError::from(no_records)), RecordType::MX).unwrap();
1010            assert_eq!(exchange.status, status);
1011            assert!(exchange.flags.is_empty() && exchange.answers.is_empty());
1012            assert!(exchange.authority.is_empty());
1013        }
1014
1015        for transport in [NetError::Timeout, NetError::NoConnections] {
1016            let err = Exchange::from_local(Err(transport), RecordType::MX).unwrap_err();
1017            assert!(matches!(&err, SeerError::DnsError(m) if m.starts_with("MX lookup failed")));
1018        }
1019    }
1020
1021    #[test]
1022    fn order_answers_puts_the_chain_first_hop_by_hop() {
1023        // Server order: target records, then the chain backwards.
1024        let answers = vec![
1025            a("edge.origin.test", "192.0.2.7"),
1026            cname("edge.cdn.test", "edge.origin.test."),
1027            cname("WWW.Seer.Test", "edge.cdn.test."),
1028        ];
1029        let ordered = order_answers("www.seer.test", RecordType::A, answers);
1030        assert_eq!(
1031            ordered,
1032            vec![
1033                cname("WWW.Seer.Test", "edge.cdn.test."),
1034                cname("edge.cdn.test", "edge.origin.test."),
1035                a("edge.origin.test", "192.0.2.7"),
1036            ]
1037        );
1038    }
1039
1040    #[test]
1041    fn order_answers_leaves_a_cname_answer_and_sorts_mx_after_the_chain() {
1042        let answers = vec![cname("www.seer.test", "edge.cdn.test.")];
1043        assert_eq!(
1044            order_answers("www.seer.test", RecordType::CNAME, answers.clone()),
1045            answers
1046        );
1047
1048        let answers = vec![
1049            mx("mail.cdn.test", 30),
1050            cname("mail.seer.test", "mail.cdn.test."),
1051            mx("mail.cdn.test", 10),
1052        ];
1053        let ordered = order_answers("mail.seer.test", RecordType::MX, answers);
1054        assert_eq!(
1055            ordered,
1056            vec![
1057                cname("mail.seer.test", "mail.cdn.test."),
1058                mx("mail.cdn.test", 10),
1059                mx("mail.cdn.test", 30),
1060            ]
1061        );
1062    }
1063
1064    #[test]
1065    fn order_answers_walks_a_chain_through_idn_names() {
1066        // Owners and CNAME targets are both A-labels (see `to_dns_record`);
1067        // only the case and the trailing dot differ.
1068        let answers = vec![
1069            a("edge.xn--caf-dma.test", "192.0.2.7"),
1070            cname("xn--bcher-kva.seer.test", "Edge.XN--CAF-DMA.test."),
1071        ];
1072        let ordered = order_answers("xn--bcher-kva.seer.test", RecordType::A, answers);
1073        assert_eq!(
1074            ordered,
1075            vec![
1076                cname("xn--bcher-kva.seer.test", "Edge.XN--CAF-DMA.test."),
1077                a("edge.xn--caf-dma.test", "192.0.2.7"),
1078            ]
1079        );
1080        assert_eq!(name_key("Edge.XN--CAF-DMA.test."), "edge.xn--caf-dma.test");
1081        assert_eq!(name_key("WWW.seer.test"), "www.seer.test");
1082    }
1083
1084    #[test]
1085    fn order_answers_terminates_on_a_cname_loop() {
1086        let answers = vec![
1087            cname("a.seer.test", "b.seer.test."),
1088            cname("b.seer.test", "a.seer.test."),
1089        ];
1090        let ordered = order_answers("a.seer.test", RecordType::A, answers);
1091        assert_eq!(ordered.len(), 2);
1092        assert_eq!(ordered[0].name, "a.seer.test");
1093    }
1094
1095    #[test]
1096    fn dedupe_keeps_the_first_copy_of_each_rr() {
1097        let mut later = cname("WWW.seer.test", "Edge.CDN.test.");
1098        later.ttl = 299;
1099        let records = vec![
1100            cname("www.seer.test", "edge.cdn.test."),
1101            a("edge.cdn.test", "192.0.2.7"),
1102            later,
1103            a("edge.cdn.test", "192.0.2.8"),
1104        ];
1105        assert_eq!(
1106            dedupe_records(records),
1107            vec![
1108                cname("www.seer.test", "edge.cdn.test."),
1109                a("edge.cdn.test", "192.0.2.7"),
1110                a("edge.cdn.test", "192.0.2.8"),
1111            ]
1112        );
1113    }
1114
1115    fn soa(zone: &str) -> DnsRecord {
1116        to_dns_record(&soa_record(zone).into_record_of_rdata()).unwrap()
1117    }
1118
1119    /// A sub-query that got `exchange`.
1120    fn answered(record_type: RecordType, exchange: Exchange) -> (RecordType, SubQuery) {
1121        (record_type, Ok(Ok(exchange)))
1122    }
1123
1124    /// A sub-query no server responded to.
1125    fn silent(record_type: RecordType) -> (RecordType, SubQuery) {
1126        (
1127            record_type,
1128            Ok(Err(NoResponse::timed_out("192.0.2.53".parse().unwrap()))),
1129        )
1130    }
1131
1132    #[test]
1133    fn merge_any_status_flags_and_answers() {
1134        let chain = cname("www.seer.test", "edge.cdn.test.");
1135        let nodata = negative(DnsStatus::NoError, vec![soa("seer.test")]);
1136        let mut authoritative = nodata.clone();
1137        authoritative.flags = vec!["qr".to_string(), "aa".to_string()];
1138        let merged = merge_any(vec![
1139            silent(RecordType::A),
1140            answered(RecordType::AAAA, authoritative),
1141            answered(
1142                RecordType::CNAME,
1143                exchange(
1144                    DnsStatus::NoError,
1145                    vec![chain.clone(), a("edge.cdn.test", "192.0.2.7")],
1146                ),
1147            ),
1148            silent(RecordType::MX),
1149            answered(
1150                RecordType::NS,
1151                exchange(DnsStatus::NoError, vec![chain.clone()]),
1152            ),
1153            // The DS sub-query, answered NODATA by the parent zone.
1154            answered(
1155                RecordType::DS,
1156                negative(DnsStatus::NoError, vec![soa("test")]),
1157            ),
1158        ])
1159        .unwrap();
1160        assert_eq!(merged.status, DnsStatus::NoError);
1161        // The flags of the first sub-query that got a response.
1162        assert_eq!(merged.flags, ["qr", "aa"]);
1163        assert_eq!(merged.answers, vec![chain, a("edge.cdn.test", "192.0.2.7")]);
1164        // Regression: the NODATA sub-queries' SOAs — the parent's among
1165        // them — were merged into a positive answer's AUTHORITY.
1166        assert!(merged.authority.is_empty(), "{:?}", merged.authority);
1167
1168        // A negative merge keeps the SOA, once, from the sub-queries that
1169        // returned the merged status.
1170        let all_nodata = merge_any(vec![
1171            answered(RecordType::A, nodata.clone()),
1172            answered(
1173                RecordType::AAAA,
1174                negative(DnsStatus::ServFail, vec![soa("other")]),
1175            ),
1176            answered(RecordType::MX, nodata),
1177        ])
1178        .unwrap();
1179        assert_eq!(all_nodata.status, DnsStatus::NoError);
1180        assert_eq!(all_nodata.authority, [soa("seer.test")]);
1181        assert!(all_nodata.failed_types.is_empty());
1182
1183        let all_nx = merge_any(vec![
1184            answered(
1185                RecordType::A,
1186                negative(DnsStatus::NxDomain, vec![soa("seer.test")]),
1187            ),
1188            answered(
1189                RecordType::MX,
1190                negative(DnsStatus::NxDomain, vec![soa("seer.test")]),
1191            ),
1192        ])
1193        .unwrap();
1194        assert_eq!(all_nx.status, DnsStatus::NxDomain);
1195        assert_eq!(
1196            all_nx.flags,
1197            ["qr", "rd", "ra"],
1198            "a negative answer has a header"
1199        );
1200        assert_eq!(all_nx.authority, [soa("seer.test")]);
1201
1202        // Mixed failures: the first sub-query's status.
1203        let mixed = merge_any(vec![
1204            answered(RecordType::A, negative(DnsStatus::ServFail, vec![])),
1205            answered(RecordType::MX, negative(DnsStatus::NxDomain, vec![])),
1206        ])
1207        .unwrap();
1208        assert_eq!(mixed.status, DnsStatus::ServFail);
1209    }
1210
1211    #[test]
1212    fn merge_any_reports_the_types_whose_sub_query_failed() {
1213        // Regression: a sub-query that timed out was dropped without a
1214        // trace, so an ANY answer missing its TXT records read as complete.
1215        let merged = merge_any(vec![
1216            answered(
1217                RecordType::A,
1218                exchange(DnsStatus::NoError, vec![a("seer.test", "192.0.2.1")]),
1219            ),
1220            silent(RecordType::TXT),
1221            (
1222                RecordType::DNSKEY,
1223                Err(SeerError::DnsError("internal detail".to_string())),
1224            ),
1225        ])
1226        .unwrap();
1227        assert_eq!(merged.answers, [a("seer.test", "192.0.2.1")]);
1228        assert_eq!(
1229            merged.failed_types,
1230            [
1231                FailedType {
1232                    record_type: RecordType::TXT,
1233                    error: "192.0.2.53: timed out".to_string(),
1234                },
1235                // An error that is not a transport failure is reported
1236                // sanitized.
1237                FailedType {
1238                    record_type: RecordType::DNSKEY,
1239                    error: "DNS resolution failed".to_string(),
1240                },
1241            ]
1242        );
1243        let result = merged.into_result(
1244            "seer.test".to_string(),
1245            RecordType::ANY,
1246            None,
1247            None,
1248            Duration::ZERO,
1249        );
1250        assert_eq!(result.failed_types.len(), 2);
1251        let json = serde_json::to_value(&result).unwrap();
1252        assert_eq!(json["failed_types"][0]["record_type"], "TXT");
1253    }
1254
1255    #[test]
1256    fn merge_any_errors_only_when_every_sub_query_failed() {
1257        let err = merge_any(vec![silent(RecordType::A), silent(RecordType::AAAA)]).unwrap_err();
1258        assert!(err.to_string().contains("AAAA lookup failed"), "{err}");
1259        assert!(err.to_string().contains("192.0.2.53: timed out"), "{err}");
1260        assert!(merge_any(vec![]).is_err());
1261    }
1262
1263    #[test]
1264    fn fold_any_keeps_successes_and_failures_apart() {
1265        let (ok, failed) = fold_any([
1266            (RecordType::A, Ok(1)),
1267            (RecordType::MX, Err("mx")),
1268            (RecordType::TXT, Ok(3)),
1269        ])
1270        .unwrap();
1271        assert_eq!(ok, [1, 3]);
1272        assert_eq!(failed, [(RecordType::MX, "mx")]);
1273        // Nothing succeeded: the last failure.
1274        let all_failed: std::result::Result<(Vec<u8>, _), _> =
1275            fold_any([(RecordType::A, Err("a")), (RecordType::MX, Err("mx"))]);
1276        assert_eq!(all_failed.unwrap_err(), "mx");
1277        let none: std::result::Result<Folded<u8, &str>, &str> = fold_any([]);
1278        assert_eq!(none.unwrap(), (vec![], vec![]));
1279    }
1280
1281    #[test]
1282    fn probe_label_is_ten_random_lowercase_hex_digits() {
1283        let label = random_probe_label();
1284        let hex = label.strip_prefix(PROBE_LABEL_PREFIX).expect("prefixed");
1285        assert_eq!(hex.len(), 10, "{label}");
1286        assert!(hex
1287            .chars()
1288            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
1289        assert_ne!(random_probe_label(), label, "40 random bits per label");
1290    }
1291
1292    #[test]
1293    fn probe_name_is_a_sibling_below_the_registrable_domain() {
1294        let probe =
1295            |name: &str, record_type| wildcard_probe_name(name, record_type, "seer-probe-x");
1296        assert_eq!(
1297            probe("www.example.com", RecordType::A).as_deref(),
1298            Some("seer-probe-x.example.com")
1299        );
1300        assert_eq!(
1301            probe("a.b.example.co.uk", RecordType::TXT).as_deref(),
1302            Some("seer-probe-x.b.example.co.uk")
1303        );
1304        // Underscore names: only the leftmost label is replaced.
1305        assert_eq!(
1306            probe("_sip._tcp.example.com", RecordType::SRV).as_deref(),
1307            Some("seer-probe-x._tcp.example.com")
1308        );
1309        assert_eq!(
1310            probe("_443._tcp.www.example.com", RecordType::TLSA).as_deref(),
1311            Some("seer-probe-x._tcp.www.example.com")
1312        );
1313    }
1314
1315    #[test]
1316    fn probe_is_never_run_at_tld_level_for_wildcards_or_for_any() {
1317        let probe =
1318            |name: &str, record_type| wildcard_probe_name(name, record_type, "seer-probe-x");
1319        // A registrable domain's sibling sits directly under a public suffix.
1320        assert_eq!(probe("example.com", RecordType::A), None);
1321        assert_eq!(probe("example.co.uk", RecordType::A), None);
1322        assert_eq!(probe("com", RecordType::A), None);
1323        // The private-section rule does not count: github.io is registrable.
1324        assert_eq!(probe("github.io", RecordType::A), None);
1325        assert_eq!(probe("*.example.com", RecordType::A), None);
1326        assert_eq!(probe("*", RecordType::A), None);
1327        assert_eq!(probe("www.example.com", RecordType::ANY), None);
1328        // Regression: the resolver answers a special-use name's sibling
1329        // itself, the same way, so the probe "found" a wildcard.
1330        assert_eq!(probe("1.0.0.127.in-addr.arpa", RecordType::PTR), None);
1331        assert_eq!(probe("a.b.localhost", RecordType::A), None);
1332    }
1333
1334    #[test]
1335    fn record_sets_compare_by_type_and_data_only() {
1336        let ours = vec![
1337            a("www.seer.test", "192.0.2.1"),
1338            a("www.seer.test", "192.0.2.2"),
1339        ];
1340        let mut theirs = vec![
1341            a("seer-probe-1.seer.test", "192.0.2.2"),
1342            a("seer-probe-1.seer.test", "192.0.2.1"),
1343        ];
1344        theirs[0].ttl = 5;
1345        assert!(same_record_set(&ours, &theirs));
1346        assert!(!same_record_set(&ours, &theirs[..1]));
1347        assert!(!same_record_set(
1348            &[cname("x.seer.test", "a.test.")],
1349            &[a("x.seer.test", "a.test.")]
1350        ));
1351    }
1352
1353    #[test]
1354    fn wildcard_outcome_needs_a_positive_answer_and_a_completed_probe() {
1355        let answer = exchange(DnsStatus::NoError, vec![a("www.seer.test", "192.0.2.1")]);
1356        let probe_answer = |address: &str| {
1357            Ok(exchange(
1358                DnsStatus::NoError,
1359                vec![a("seer-probe-1.seer.test", address)],
1360            ))
1361        };
1362        let judge = |answer: &Exchange, probe| {
1363            wildcard_outcome("seer-probe-1.seer.test", RecordType::A, answer, probe)
1364        };
1365
1366        let matched = judge(&answer, probe_answer("192.0.2.1")).unwrap();
1367        assert_eq!(
1368            matched,
1369            WildcardProbe {
1370                probe_name: "seer-probe-1.seer.test".to_string(),
1371                present: true,
1372                matches_answer: true,
1373            }
1374        );
1375        let different = judge(&answer, probe_answer("192.0.2.99")).unwrap();
1376        assert!(different.present && !different.matches_answer);
1377
1378        let absent = judge(&answer, Ok(negative(DnsStatus::NxDomain, vec![]))).unwrap();
1379        assert!(!absent.present && !absent.matches_answer);
1380        let nodata = judge(&answer, Ok(negative(DnsStatus::NoError, vec![]))).unwrap();
1381        assert!(!nodata.present);
1382
1383        // The probe did not complete: nothing to say.
1384        assert_eq!(
1385            judge(&answer, Ok(negative(DnsStatus::ServFail, vec![]))),
1386            None
1387        );
1388        assert_eq!(
1389            judge(&answer, Err(SeerError::DnsError("timeout".to_string()))),
1390            None
1391        );
1392
1393        // Nothing to judge against: a negative main answer, or a bare chain.
1394        let nx = negative(DnsStatus::NxDomain, vec![]);
1395        assert_eq!(judge(&nx, probe_answer("192.0.2.1")), None);
1396        let chain_only = exchange(
1397            DnsStatus::NoError,
1398            vec![cname("www.seer.test", "edge.cdn.test.")],
1399        );
1400        assert_eq!(judge(&chain_only, probe_answer("192.0.2.1")), None);
1401    }
1402
1403    fn result(
1404        record_type: RecordType,
1405        status: DnsStatus,
1406        answers: Vec<DnsRecord>,
1407    ) -> DnsQueryResult {
1408        exchange(status, answers).into_result(
1409            "www.seer.test".to_string(),
1410            record_type,
1411            None,
1412            None,
1413            Duration::from_millis(12),
1414        )
1415    }
1416
1417    #[test]
1418    fn result_splits_the_chain_from_the_records() {
1419        let chained = result(
1420            RecordType::A,
1421            DnsStatus::NoError,
1422            vec![
1423                cname("www.seer.test", "edge.cdn.test."),
1424                a("edge.cdn.test", "192.0.2.7"),
1425            ],
1426        );
1427        assert_eq!(chained.cname_chain().count(), 1);
1428        assert_eq!(
1429            chained
1430                .records()
1431                .map(|r| r.name.as_str())
1432                .collect::<Vec<_>>(),
1433            ["edge.cdn.test"]
1434        );
1435        assert!(!chained.is_nodata());
1436        assert_eq!(chained.query_time_ms, 12);
1437
1438        // A CNAME query's CNAME is the answer, not a hop.
1439        let cname_query = result(
1440            RecordType::CNAME,
1441            DnsStatus::NoError,
1442            vec![cname("www.seer.test", "edge.cdn.test.")],
1443        );
1444        assert_eq!(cname_query.cname_chain().count(), 0);
1445        assert_eq!(cname_query.records().count(), 1);
1446        assert!(!cname_query.is_nodata());
1447    }
1448
1449    #[test]
1450    fn nodata_means_noerror_without_records_of_the_type() {
1451        assert!(result(RecordType::A, DnsStatus::NoError, vec![]).is_nodata());
1452        // A chain that ends without the type is still NODATA for it.
1453        assert!(result(
1454            RecordType::A,
1455            DnsStatus::NoError,
1456            vec![cname("www.seer.test", "edge.cdn.test.")]
1457        )
1458        .is_nodata());
1459        assert!(!result(RecordType::A, DnsStatus::NxDomain, vec![]).is_nodata());
1460        assert!(!result(RecordType::A, DnsStatus::ServFail, vec![]).is_nodata());
1461        // ANY: NODATA only with no answers at all.
1462        assert!(result(RecordType::ANY, DnsStatus::NoError, vec![]).is_nodata());
1463        assert!(!result(
1464            RecordType::ANY,
1465            DnsStatus::NoError,
1466            vec![mx("www.seer.test", 10)]
1467        )
1468        .is_nodata());
1469    }
1470
1471    fn ns(zone: &str, host: &str) -> DnsRecord {
1472        rec(
1473            zone,
1474            RecordType::NS,
1475            300,
1476            RecordData::NS {
1477                nameserver: host.to_string(),
1478            },
1479        )
1480    }
1481
1482    #[test]
1483    fn a_referral_is_not_nodata() {
1484        // Regression: a non-recursive server's referral (NOERROR, no answer,
1485        // the child zone's NS records in AUTHORITY, no SOA) was reported as
1486        // NODATA — "the name exists" — which a referral does not say.
1487        let mut referral = result(RecordType::A, DnsStatus::NoError, vec![]);
1488        referral.authority = vec![
1489            ns("child.seer.test", "ns1.child.seer.test."),
1490            ns("child.seer.test", "ns2.child.seer.test."),
1491        ];
1492        assert_eq!(referral.referral_zone(), Some("child.seer.test"));
1493        assert!(!referral.is_nodata());
1494
1495        // NODATA may carry the zone's NS next to its SOA (RFC 2308 §2.2):
1496        // the SOA makes it an answer, not a referral.
1497        let mut nodata = referral.clone();
1498        nodata.authority.insert(0, soa("seer.test"));
1499        assert_eq!(nodata.referral_zone(), None);
1500        assert!(nodata.is_nodata());
1501
1502        // NS records beside an answer, or under another status, are no
1503        // referral either.
1504        let mut answered = result(
1505            RecordType::A,
1506            DnsStatus::NoError,
1507            vec![a("www.seer.test", "192.0.2.1")],
1508        );
1509        answered.authority = referral.authority.clone();
1510        assert_eq!(answered.referral_zone(), None);
1511        let mut nx = result(RecordType::A, DnsStatus::NxDomain, vec![]);
1512        nx.authority = referral.authority.clone();
1513        assert_eq!(nx.referral_zone(), None);
1514        // An ANY fan-out whose sub-queries were all referred.
1515        let mut any = referral.clone();
1516        any.record_type = RecordType::ANY;
1517        assert_eq!(any.referral_zone(), Some("child.seer.test"));
1518        assert!(!any.is_nodata());
1519
1520        // An authoritative server's answer is never a referral: here its own
1521        // zone's NS records sit beside a CNAME it did not follow out of the
1522        // zone, with no SOA. The AA flag tells the two apart.
1523        let mut unfollowed = result(
1524            RecordType::A,
1525            DnsStatus::NoError,
1526            vec![cname("www.seer.test", "edge.cdn.test.")],
1527        );
1528        unfollowed.flags = vec!["qr".to_string(), "aa".to_string(), "rd".to_string()];
1529        unfollowed.authority = vec![ns("seer.test", "ns1.seer.test.")];
1530        assert_eq!(unfollowed.referral_zone(), None);
1531        unfollowed.flags.retain(|flag| flag != "aa");
1532        assert_eq!(unfollowed.referral_zone(), Some("seer.test"));
1533    }
1534
1535    #[test]
1536    fn special_use_names_are_answered_locally() {
1537        for name in [
1538            "localhost",
1539            "foo.localhost",
1540            "1.0.0.127.in-addr.arpa",
1541            "seer-probe-0123456789.0.0.127.in-addr.arpa",
1542            "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa",
1543            "x.invalid",
1544            "facebookcorewwwi.onion",
1545            "Foo.LOCALHOST",
1546        ] {
1547            assert!(answered_locally(name), "{name}");
1548        }
1549        for name in [
1550            "www.seer.test",
1551            // `local` is consulted, but hickory sends its queries on.
1552            "printer.local",
1553            "1.0.0.10.in-addr.arpa",
1554            "localhost.example.com",
1555            "onion.seer.test",
1556        ] {
1557            assert!(!answered_locally(name), "{name}");
1558        }
1559    }
1560
1561    #[test]
1562    fn a_local_answer_names_no_server_and_no_invented_header() {
1563        // Regression: hickory's own answer for a special-use name was
1564        // reported as the server's — `server: 1.1.1.1`, `flags: qr` — though
1565        // no query was sent.
1566        let local = exchange(
1567            DnsStatus::NoError,
1568            vec![rec(
1569                "1.0.0.127.in-addr.arpa",
1570                RecordType::PTR,
1571                86400,
1572                RecordData::PTR {
1573                    target: "localhost.".to_string(),
1574                },
1575            )],
1576        )
1577        .into_result(
1578            "1.0.0.127.in-addr.arpa".to_string(),
1579            RecordType::PTR,
1580            Some("1.1.1.1"),
1581            None,
1582            Duration::ZERO,
1583        );
1584        assert!(local.answered_locally);
1585        assert_eq!(local.server, None);
1586        assert!(local.flags.is_empty());
1587        assert_eq!(local.records().count(), 1);
1588
1589        let sent = exchange(DnsStatus::NoError, vec![]).into_result(
1590            "www.seer.test".to_string(),
1591            RecordType::A,
1592            Some("1.1.1.1"),
1593            None,
1594            Duration::ZERO,
1595        );
1596        assert_eq!(sent.server.as_deref(), Some("1.1.1.1"));
1597        assert!(!sent.answered_locally);
1598        assert_eq!(sent.flags, ["qr", "rd", "ra"]);
1599    }
1600
1601    #[test]
1602    fn result_serializes_to_the_documented_shape() {
1603        let mut query = result(
1604            RecordType::A,
1605            DnsStatus::NoError,
1606            vec![
1607                cname("www.seer.test", "edge.cdn.test."),
1608                a("edge.cdn.test", "192.0.2.7"),
1609            ],
1610        );
1611        query.server = Some("1.1.1.1".to_string());
1612        query.wildcard = Some(WildcardProbe {
1613            probe_name: "seer-probe-3f9a1c2e7b.seer.test".to_string(),
1614            present: false,
1615            matches_answer: false,
1616        });
1617        let json = serde_json::to_value(&query).unwrap();
1618        assert_eq!(
1619            json,
1620            serde_json::json!({
1621                "name": "www.seer.test",
1622                "record_type": "A",
1623                "server": "1.1.1.1",
1624                "answered_locally": false,
1625                "status": "NOERROR",
1626                "flags": ["qr", "rd", "ra"],
1627                "answers": [
1628                    {
1629                        "name": "www.seer.test",
1630                        "record_type": "CNAME",
1631                        "ttl": 300,
1632                        "data": {"record_type": "CNAME", "value": {"target": "edge.cdn.test."}}
1633                    },
1634                    {
1635                        "name": "edge.cdn.test",
1636                        "record_type": "A",
1637                        "ttl": 300,
1638                        "data": {"record_type": "A", "value": {"address": "192.0.2.7"}}
1639                    }
1640                ],
1641                "authority": [],
1642                "wildcard": {
1643                    "probe_name": "seer-probe-3f9a1c2e7b.seer.test",
1644                    "present": false,
1645                    "matches_answer": false
1646                },
1647                "query_time_ms": 12
1648            })
1649        );
1650        let back: DnsQueryResult = serde_json::from_value(json).unwrap();
1651        assert_eq!(back, query);
1652
1653        // The default upstream and an unrun probe serialize as null.
1654        let default = result(RecordType::A, DnsStatus::NxDomain, vec![]);
1655        let json = serde_json::to_value(&default).unwrap();
1656        assert_eq!(json["server"], serde_json::Value::Null);
1657        assert_eq!(json["wildcard"], serde_json::Value::Null);
1658        assert_eq!(json["status"], "NXDOMAIN");
1659    }
1660}