1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum DnsStatus {
63 NoError,
68 NxDomain,
71 ServFail,
74 Refused,
76 Other(u16),
78}
79
80impl DnsStatus {
81 pub fn is_success(self) -> bool {
83 self == DnsStatus::NoError
84 }
85
86 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 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
109const 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct DnsQueryResult {
203 pub name: String,
206 pub record_type: RecordType,
207 pub server: Option<String>,
211 pub answered_locally: bool,
217 pub status: DnsStatus,
218 pub flags: Vec<String>,
225 pub answers: Vec<DnsRecord>,
237 pub authority: Vec<DnsRecord>,
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub failed_types: Vec<FailedType>,
251 pub wildcard: Option<WildcardProbe>,
254 pub query_time_ms: u64,
256}
257
258impl DnsQueryResult {
259 pub fn is_nodata(&self) -> bool {
263 self.status == DnsStatus::NoError && self.lacks_answer() && self.referral_zone().is_none()
264 }
265
266 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 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 pub fn cname_chain(&self) -> impl Iterator<Item = &DnsRecord> {
304 self.answers[..chain_len(self.record_type, &self.answers)].iter()
305 }
306
307 pub fn records(&self) -> impl Iterator<Item = &DnsRecord> {
310 self.answers[chain_len(self.record_type, &self.answers)..].iter()
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct FailedType {
318 pub record_type: RecordType,
319 pub error: String,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct WildcardProbe {
333 pub probe_name: String,
336 pub present: bool,
339 pub matches_answer: bool,
344}
345
346fn 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#[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 pub(crate) failed_types: Vec<FailedType>,
370}
371
372impl Exchange {
373 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 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 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 pub(crate) fn records(&self, record_type: RecordType) -> &[DnsRecord] {
426 &self.answers[chain_len(record_type, &self.answers)..]
427 }
428
429 pub(crate) fn has_records(&self, record_type: RecordType) -> bool {
432 self.status == DnsStatus::NoError && !self.records(record_type).is_empty()
433 }
434
435 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
463pub(crate) fn duration_ms(elapsed: Duration) -> u64 {
466 u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
467}
468
469pub(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
487fn name_key(name: &str) -> String {
492 name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase()
493}
494
495pub(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 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
531pub(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
549pub(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 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
618pub(crate) type SubQuery = Result<std::result::Result<Exchange, NoResponse>>;
621
622pub(crate) type Folded<T, E> = (Vec<T>, Vec<(RecordType, E)>);
624
625pub(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
648pub(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
677const PROBE_LABEL_PREFIX: &str = "seer-probe-";
679
680pub(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
688pub(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
715pub(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
727pub(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 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 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 let query = Metadata::new(7, MessageType::Query, OpCode::Query);
919 assert!(header_flags(&query).is_empty());
920 }
921
922 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 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 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 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 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 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 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 fn answered(record_type: RecordType, exchange: Exchange) -> (RecordType, SubQuery) {
1121 (record_type, Ok(Ok(exchange)))
1122 }
1123
1124 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 answered(
1155 RecordType::DS,
1156 negative(DnsStatus::NoError, vec![soa("test")]),
1157 ),
1158 ])
1159 .unwrap();
1160 assert_eq!(merged.status, DnsStatus::NoError);
1161 assert_eq!(merged.flags, ["qr", "aa"]);
1163 assert_eq!(merged.answers, vec![chain, a("edge.cdn.test", "192.0.2.7")]);
1164 assert!(merged.authority.is_empty(), "{:?}", merged.authority);
1167
1168 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 "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 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 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}