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