1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5use crate::error::{Result, SeerError};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "UPPERCASE")]
9pub enum RecordType {
10 A,
11 AAAA,
12 CNAME,
13 MX,
14 NS,
15 TXT,
16 SOA,
17 PTR,
18 SRV,
19 CAA,
20 NAPTR,
21 DNSKEY,
22 DS,
23 CDS,
24 CDNSKEY,
25 TLSA,
26 SSHFP,
27 HTTPS,
28 SVCB,
29 ANY,
30}
31
32impl RecordType {
33 pub const ALL: &'static [RecordType] = &[
41 RecordType::A,
42 RecordType::AAAA,
43 RecordType::CNAME,
44 RecordType::MX,
45 RecordType::NS,
46 RecordType::TXT,
47 RecordType::SOA,
48 RecordType::PTR,
49 RecordType::SRV,
50 RecordType::CAA,
51 RecordType::NAPTR,
52 RecordType::DNSKEY,
53 RecordType::DS,
54 RecordType::CDS,
55 RecordType::CDNSKEY,
56 RecordType::TLSA,
57 RecordType::SSHFP,
58 RecordType::HTTPS,
59 RecordType::SVCB,
60 RecordType::ANY,
61 ];
62
63 pub const ALL_NAMES: &'static [&'static str] = &{
67 let mut names = [""; RecordType::ALL.len()];
68 let mut i = 0;
69 while i < names.len() {
70 names[i] = RecordType::ALL[i].as_str();
71 i += 1;
72 }
73 names
74 };
75
76 pub const fn as_str(&self) -> &'static str {
79 match self {
80 RecordType::A => "A",
81 RecordType::AAAA => "AAAA",
82 RecordType::CNAME => "CNAME",
83 RecordType::MX => "MX",
84 RecordType::NS => "NS",
85 RecordType::TXT => "TXT",
86 RecordType::SOA => "SOA",
87 RecordType::PTR => "PTR",
88 RecordType::SRV => "SRV",
89 RecordType::CAA => "CAA",
90 RecordType::NAPTR => "NAPTR",
91 RecordType::DNSKEY => "DNSKEY",
92 RecordType::DS => "DS",
93 RecordType::CDS => "CDS",
94 RecordType::CDNSKEY => "CDNSKEY",
95 RecordType::TLSA => "TLSA",
96 RecordType::SSHFP => "SSHFP",
97 RecordType::HTTPS => "HTTPS",
98 RecordType::SVCB => "SVCB",
99 RecordType::ANY => "ANY",
100 }
101 }
102}
103
104impl fmt::Display for RecordType {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 f.write_str(self.as_str())
107 }
108}
109
110impl FromStr for RecordType {
111 type Err = SeerError;
112
113 fn from_str(s: &str) -> Result<Self> {
116 if s == "*" {
117 return Ok(RecordType::ANY);
118 }
119 RecordType::ALL
120 .iter()
121 .copied()
122 .find(|t| t.as_str().eq_ignore_ascii_case(s))
123 .ok_or_else(|| SeerError::InvalidRecordType(s.to_string()))
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct DnsRecord {
129 pub name: String,
130 pub record_type: RecordType,
131 pub ttl: u32,
132 pub data: RecordData,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct SvcParam {
138 pub key: String,
142 pub value: String,
146}
147
148impl fmt::Display for SvcParam {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 if self.value.is_empty() {
153 f.write_str(&self.key)
154 } else if self.key == "alpn" {
155 write!(f, "{}=\"{}\"", self.key, self.value)
156 } else {
157 write!(f, "{}={}", self.key, self.value)
158 }
159 }
160}
161
162fn write_svcb(
166 f: &mut fmt::Formatter<'_>,
167 priority: u16,
168 target: &str,
169 params: &[SvcParam],
170) -> fmt::Result {
171 write!(f, "{} {}", priority, target)?;
172 for param in params {
173 write!(f, " {}", param)?;
174 }
175 Ok(())
176}
177
178fn write_txt(f: &mut fmt::Formatter<'_>, text: &str, strings: &[String]) -> fmt::Result {
182 let whole = [text.to_string()];
183 let strings = if strings.is_empty() {
184 &whole[..]
185 } else {
186 strings
187 };
188 for (i, string) in strings.iter().enumerate() {
189 if i > 0 {
190 f.write_str(" ")?;
191 }
192 f.write_str("e_char_string(string))?;
193 }
194 Ok(())
195}
196
197fn quote_char_string(s: &str) -> String {
202 let mut out = String::with_capacity(s.len() + 2);
203 out.push('"');
204 for c in s.chars() {
205 if matches!(c, '"' | '\\') {
206 out.push('\\');
207 }
208 out.push(c);
209 }
210 out.push('"');
211 out
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(tag = "record_type", content = "value", rename_all = "UPPERCASE")]
216#[allow(clippy::upper_case_acronyms)]
217pub enum RecordData {
218 A {
219 address: String,
220 },
221 AAAA {
222 address: String,
223 },
224 CNAME {
225 target: String,
226 },
227 MX {
228 preference: u16,
229 exchange: String,
230 },
231 NS {
232 nameserver: String,
233 },
234 TXT {
238 text: String,
242 #[serde(default)]
248 strings: Vec<String>,
249 },
250 SOA {
251 mname: String,
252 rname: String,
253 serial: u32,
254 refresh: u32,
255 retry: u32,
256 expire: u32,
257 minimum: u32,
258 },
259 PTR {
260 target: String,
261 },
262 SRV {
263 priority: u16,
264 weight: u16,
265 port: u16,
266 target: String,
267 },
268 CAA {
269 flags: u8,
270 tag: String,
271 value: String,
272 },
273 DNSKEY {
274 flags: u16,
275 protocol: u8,
276 algorithm: u8,
277 public_key: String,
278 },
279 DS {
280 key_tag: u16,
281 algorithm: u8,
282 digest_type: u8,
283 digest: String,
284 },
285 CDS {
289 key_tag: u16,
290 algorithm: u8,
291 digest_type: u8,
292 digest: String,
294 },
295 CDNSKEY {
298 flags: u16,
299 protocol: u8,
300 algorithm: u8,
301 public_key: String,
303 },
304 TLSA {
305 cert_usage: u8,
306 selector: u8,
307 matching: u8,
308 cert_data: String,
310 },
311 SSHFP {
312 algorithm: u8,
313 fingerprint_type: u8,
314 fingerprint: String,
316 },
317 NAPTR {
318 order: u16,
319 preference: u16,
320 flags: String,
321 services: String,
322 regexp: String,
323 replacement: String,
324 },
325 HTTPS {
328 priority: u16,
329 target: String,
331 params: Vec<SvcParam>,
332 },
333 SVCB {
336 priority: u16,
337 target: String,
339 params: Vec<SvcParam>,
340 },
341}
342
343impl fmt::Display for RecordData {
344 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345 match self {
346 RecordData::A { address } => write!(f, "{}", address),
347 RecordData::AAAA { address } => write!(f, "{}", address),
348 RecordData::CNAME { target } => write!(f, "{}", target),
349 RecordData::MX {
350 preference,
351 exchange,
352 } => write!(f, "{} {}", preference, exchange),
353 RecordData::NS { nameserver } => write!(f, "{}", nameserver),
354 RecordData::TXT { text, strings } => write_txt(f, text, strings),
355 RecordData::SOA {
356 mname,
357 rname,
358 serial,
359 refresh,
360 retry,
361 expire,
362 minimum,
363 } => write!(
364 f,
365 "{} {} {} {} {} {} {}",
366 mname, rname, serial, refresh, retry, expire, minimum
367 ),
368 RecordData::PTR { target } => write!(f, "{}", target),
369 RecordData::SRV {
370 priority,
371 weight,
372 port,
373 target,
374 } => write!(f, "{} {} {} {}", priority, weight, port, target),
375 RecordData::CAA { flags, tag, value } => write!(f, "{} {} \"{}\"", flags, tag, value),
376 RecordData::DNSKEY {
377 flags,
378 protocol,
379 algorithm,
380 public_key,
381 } => write!(f, "{} {} {} {}", flags, protocol, algorithm, public_key),
382 RecordData::DS {
383 key_tag,
384 algorithm,
385 digest_type,
386 digest,
387 } => write!(f, "{} {} {} {}", key_tag, algorithm, digest_type, digest),
388 RecordData::CDS {
389 key_tag,
390 algorithm,
391 digest_type,
392 digest,
393 } => write!(f, "{} {} {} {}", key_tag, algorithm, digest_type, digest),
394 RecordData::CDNSKEY {
395 flags,
396 protocol,
397 algorithm,
398 public_key,
399 } => write!(f, "{} {} {} {}", flags, protocol, algorithm, public_key),
400 RecordData::TLSA {
401 cert_usage,
402 selector,
403 matching,
404 cert_data,
405 } => write!(f, "{} {} {} {}", cert_usage, selector, matching, cert_data),
406 RecordData::SSHFP {
407 algorithm,
408 fingerprint_type,
409 fingerprint,
410 } => write!(f, "{} {} {}", algorithm, fingerprint_type, fingerprint),
411 RecordData::NAPTR {
412 order,
413 preference,
414 flags,
415 services,
416 regexp,
417 replacement,
418 } => write!(
419 f,
420 "{} {} \"{}\" \"{}\" \"{}\" {}",
421 order, preference, flags, services, regexp, replacement
422 ),
423 RecordData::HTTPS {
424 priority,
425 target,
426 params,
427 }
428 | RecordData::SVCB {
429 priority,
430 target,
431 params,
432 } => write_svcb(f, *priority, target, params),
433 }
434 }
435}
436
437impl RecordData {
438 pub fn txt(strings: Vec<String>) -> Self {
440 RecordData::TXT {
441 text: strings.concat(),
442 strings,
443 }
444 }
445
446 pub(crate) fn address(&self) -> Option<&str> {
448 match self {
449 RecordData::A { address } | RecordData::AAAA { address } => Some(address),
450 _ => None,
451 }
452 }
453
454 pub(crate) fn comparison_key(&self) -> String {
469 let mut folded = self.clone();
470 match &mut folded {
471 RecordData::CNAME { target }
472 | RecordData::PTR { target }
473 | RecordData::SRV { target, .. }
474 | RecordData::HTTPS { target, .. }
475 | RecordData::SVCB { target, .. } => target.make_ascii_lowercase(),
476 RecordData::NS { nameserver } => nameserver.make_ascii_lowercase(),
477 RecordData::MX { exchange, .. } => exchange.make_ascii_lowercase(),
478 RecordData::SOA { mname, rname, .. } => {
479 mname.make_ascii_lowercase();
480 rname.make_ascii_lowercase();
481 }
482 RecordData::NAPTR { replacement, .. } => replacement.make_ascii_lowercase(),
483 RecordData::CAA { tag, .. } => tag.make_ascii_lowercase(),
486 RecordData::A { .. }
487 | RecordData::AAAA { .. }
488 | RecordData::TXT { .. }
489 | RecordData::DNSKEY { .. }
490 | RecordData::DS { .. }
491 | RecordData::CDS { .. }
492 | RecordData::CDNSKEY { .. }
493 | RecordData::TLSA { .. }
494 | RecordData::SSHFP { .. } => {}
495 }
496 folded.to_string()
497 }
498}
499
500impl DnsRecord {
501 pub fn format_short(&self) -> String {
502 format!("{}", self.data)
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn test_record_type_from_str() {
512 assert_eq!("A".parse::<RecordType>().unwrap(), RecordType::A);
513 assert_eq!("aaaa".parse::<RecordType>().unwrap(), RecordType::AAAA);
514 assert_eq!("MX".parse::<RecordType>().unwrap(), RecordType::MX);
515 assert_eq!("*".parse::<RecordType>().unwrap(), RecordType::ANY);
516 assert!("INVALID".parse::<RecordType>().is_err());
517 }
518
519 #[test]
520 fn record_type_from_str_is_ascii_case_insensitive() {
521 for (name, expected) in [
522 ("a", RecordType::A),
523 ("mx", RecordType::MX),
524 ("Mx", RecordType::MX),
525 ("cNaMe", RecordType::CNAME),
526 ("dnskey", RecordType::DNSKEY),
527 ("any", RecordType::ANY),
528 ("ANY", RecordType::ANY),
529 ] {
530 assert_eq!(name.parse::<RecordType>().unwrap(), expected, "{name}");
531 }
532 assert!("ſrv".parse::<RecordType>().is_err());
535 assert!("ſoa".parse::<RecordType>().is_err());
536 }
537
538 #[test]
539 fn record_type_from_str_rejects_padding_and_unknown_names() {
540 for bad in [" A", "A ", "\tA\n", "NOTAREAL", "A1", "", "**"] {
542 assert!(bad.parse::<RecordType>().is_err(), "{bad:?}");
543 }
544 }
545
546 #[test]
547 fn test_record_type_display() {
548 assert_eq!(RecordType::A.to_string(), "A");
549 assert_eq!(RecordType::AAAA.to_string(), "AAAA");
550 assert_eq!(RecordType::MX.to_string(), "MX");
551 assert_eq!(RecordType::SOA.to_string(), "SOA");
552 }
553
554 #[test]
555 fn test_dns_record_format_short() {
556 let record = DnsRecord {
557 name: "example.com".to_string(),
558 record_type: RecordType::A,
559 ttl: 300,
560 data: RecordData::A {
561 address: "1.2.3.4".to_string(),
562 },
563 };
564 assert_eq!(record.format_short(), "1.2.3.4");
565 }
566
567 #[test]
568 fn test_record_data_display() {
569 let mx = RecordData::MX {
570 preference: 10,
571 exchange: "mail.example.com".to_string(),
572 };
573 assert_eq!(format!("{}", mx), "10 mail.example.com");
574
575 let txt = RecordData::txt(vec!["v=spf1 include:example.com".to_string()]);
576 assert_eq!(format!("{}", txt), "\"v=spf1 include:example.com\"");
577
578 let srv = RecordData::SRV {
579 priority: 10,
580 weight: 5,
581 port: 443,
582 target: "server.example.com".to_string(),
583 };
584 assert_eq!(format!("{}", srv), "10 5 443 server.example.com");
585 }
586
587 #[test]
588 fn test_record_serialization_roundtrip() {
589 let record = DnsRecord {
590 name: "example.com".to_string(),
591 record_type: RecordType::A,
592 ttl: 300,
593 data: RecordData::A {
594 address: "1.2.3.4".to_string(),
595 },
596 };
597 let json = serde_json::to_string(&record).unwrap();
598 assert!(json.contains("\"A\""));
599 assert!(json.contains("1.2.3.4"));
600 }
601
602 #[test]
603 fn test_soa_display() {
604 let soa = RecordData::SOA {
605 mname: "ns1.example.com".to_string(),
606 rname: "admin.example.com".to_string(),
607 serial: 2024010101,
608 refresh: 3600,
609 retry: 900,
610 expire: 604800,
611 minimum: 86400,
612 };
613 let display = format!("{}", soa);
614 assert!(display.contains("ns1.example.com"));
615 assert!(display.contains("2024010101"));
616 }
617
618 #[test]
619 fn test_naptr_display() {
620 let naptr = RecordData::NAPTR {
622 order: 100,
623 preference: 50,
624 flags: "s".to_string(),
625 services: "http+N2L+N2C+N2R".to_string(),
626 regexp: String::new(),
627 replacement: "www.example.com.".to_string(),
628 };
629 assert_eq!(
630 format!("{}", naptr),
631 "100 50 \"s\" \"http+N2L+N2C+N2R\" \"\" www.example.com."
632 );
633 }
634
635 #[test]
636 fn comparison_key_folds_only_domain_name_fields() {
637 let ns = |n: &str| RecordData::NS {
638 nameserver: n.to_string(),
639 };
640 assert_eq!(
641 ns("NS1.Example.COM.").comparison_key(),
642 ns("ns1.example.com.").comparison_key()
643 );
644 let mx = RecordData::MX {
645 preference: 10,
646 exchange: "MX.Example.com.".to_string(),
647 };
648 assert_eq!(mx.comparison_key(), "10 mx.example.com.");
649 let cname = RecordData::CNAME {
650 target: "Edge.CDN.test.".to_string(),
651 };
652 assert_eq!(cname.comparison_key(), "edge.cdn.test.");
653
654 let txt = |t: &str| RecordData::txt(vec![t.to_string()]);
657 assert_ne!(
658 txt("verify=AbC").comparison_key(),
659 txt("verify=abc").comparison_key()
660 );
661 let key = |k: &str| RecordData::DNSKEY {
662 flags: 257,
663 protocol: 3,
664 algorithm: 13,
665 public_key: k.to_string(),
666 };
667 assert_ne!(
668 key("mdsswUyr3DPW").comparison_key(),
669 key("MDSSWUYR3DPW").comparison_key()
670 );
671 }
672
673 fn svc(key: &str, value: &str) -> SvcParam {
674 SvcParam {
675 key: key.to_string(),
676 value: value.to_string(),
677 }
678 }
679
680 #[test]
681 fn https_display_mirrors_dig() {
682 let https = RecordData::HTTPS {
683 priority: 1,
684 target: ".".to_string(),
685 params: vec![
686 svc("alpn", "h3,h2"),
687 svc("ipv4hint", "104.16.132.229,104.16.133.229"),
688 ],
689 };
690 assert_eq!(
691 https.to_string(),
692 "1 . alpn=\"h3,h2\" ipv4hint=104.16.132.229,104.16.133.229"
693 );
694
695 let svcb = RecordData::SVCB {
697 priority: 2,
698 target: "svc.example.net.".to_string(),
699 params: vec![svc("no-default-alpn", ""), svc("port", "8443")],
700 };
701 assert_eq!(
702 svcb.to_string(),
703 "2 svc.example.net. no-default-alpn port=8443"
704 );
705
706 let alias = RecordData::HTTPS {
708 priority: 0,
709 target: "pool.example.net.".to_string(),
710 params: vec![],
711 };
712 assert_eq!(alias.to_string(), "0 pool.example.net.");
713 }
714
715 #[test]
716 fn child_dnssec_records_display_like_their_parents() {
717 let cds = RecordData::CDS {
718 key_tag: 2371,
719 algorithm: 13,
720 digest_type: 2,
721 digest: "ABCDEF01".to_string(),
722 };
723 assert_eq!(cds.to_string(), "2371 13 2 ABCDEF01");
724 let cdnskey = RecordData::CDNSKEY {
725 flags: 257,
726 protocol: 3,
727 algorithm: 13,
728 public_key: "mdsswUyr3DPW".to_string(),
729 };
730 assert_eq!(cdnskey.to_string(), "257 3 13 mdsswUyr3DPW");
731 let delete = RecordData::CDNSKEY {
733 flags: 0,
734 protocol: 3,
735 algorithm: 0,
736 public_key: "AA==".to_string(),
737 };
738 assert_eq!(delete.to_string(), "0 3 0 AA==");
739 }
740
741 #[test]
742 fn comparison_key_folds_the_service_target_only() {
743 let https = |target: &str, alpn: &str| RecordData::HTTPS {
744 priority: 1,
745 target: target.to_string(),
746 params: vec![svc("alpn", alpn)],
747 };
748 assert_eq!(
749 https("Svc.Example.NET.", "h2").comparison_key(),
750 https("svc.example.net.", "h2").comparison_key()
751 );
752 assert_ne!(
754 https(".", "H2").comparison_key(),
755 https(".", "h2").comparison_key()
756 );
757 let cds = |digest: &str| RecordData::CDS {
758 key_tag: 1,
759 algorithm: 13,
760 digest_type: 2,
761 digest: digest.to_string(),
762 };
763 assert_ne!(cds("ABCD").comparison_key(), cds("abcd").comparison_key());
764 }
765
766 #[test]
767 fn https_serializes_with_structured_params() {
768 let record = DnsRecord {
769 name: "example.com".to_string(),
770 record_type: RecordType::HTTPS,
771 ttl: 300,
772 data: RecordData::HTTPS {
773 priority: 1,
774 target: ".".to_string(),
775 params: vec![svc("alpn", "h2")],
776 },
777 };
778 let json = serde_json::to_value(&record).unwrap();
779 assert_eq!(json["record_type"], "HTTPS");
780 assert_eq!(json["data"]["record_type"], "HTTPS");
781 assert_eq!(json["data"]["value"]["priority"], 1);
782 assert_eq!(json["data"]["value"]["target"], ".");
783 assert_eq!(
784 json["data"]["value"]["params"],
785 serde_json::json!([{"key": "alpn", "value": "h2"}])
786 );
787 let back: DnsRecord = serde_json::from_value(json).unwrap();
788 assert_eq!(back, record);
789 }
790
791 #[test]
792 fn new_record_types_parse_by_name() {
793 for (name, expected) in [
794 ("https", RecordType::HTTPS),
795 ("SVCB", RecordType::SVCB),
796 ("cds", RecordType::CDS),
797 ("CDNSKEY", RecordType::CDNSKEY),
798 ] {
799 assert_eq!(name.parse::<RecordType>().unwrap(), expected);
800 }
801 assert_eq!(RecordType::ALL.last(), Some(&RecordType::ANY));
803 }
804
805 #[test]
811 fn all_is_complete() {
812 assert_eq!(
813 RecordType::ALL.len(),
814 20,
815 "new RecordType variant — add it to ALL too"
816 );
817 assert_eq!(RecordType::ALL.len(), RecordType::ALL_NAMES.len());
818 }
819
820 fn txt(strings: &[&str]) -> RecordData {
821 RecordData::txt(strings.iter().map(|s| s.to_string()).collect())
822 }
823
824 #[test]
825 fn txt_displays_each_string_quoted_and_escaped_like_dig() {
826 let split = txt(&["v=spf1 include:_spf.example.com", " -all"]);
827 assert_eq!(
828 split.to_string(),
829 r#""v=spf1 include:_spf.example.com" " -all""#
830 );
831 assert!(matches!(
833 &split,
834 RecordData::TXT { text, .. } if text == "v=spf1 include:_spf.example.com -all"
835 ));
836 assert_eq!(txt(&["a\"b", "c\\d"]).to_string(), r#""a\"b" "c\\d""#);
839 assert_eq!(txt(&[""]).to_string(), r#""""#);
840 let legacy: RecordData =
842 serde_json::from_str(r#"{"record_type":"TXT","value":{"text":"a b"}}"#).unwrap();
843 assert_eq!(legacy.to_string(), r#""a b""#);
844 assert_eq!(legacy.comparison_key(), txt(&["a b"]).comparison_key());
845 }
846
847 #[test]
848 fn txt_comparison_key_keeps_string_boundaries() {
849 assert_ne!(
852 txt(&["ab", "c"]).comparison_key(),
853 txt(&["a", "bc"]).comparison_key()
854 );
855 assert_ne!(
856 txt(&["a\" \"b"]).comparison_key(),
857 txt(&["a", "b"]).comparison_key()
858 );
859 assert_eq!(
860 txt(&["ab", "c"]).comparison_key(),
861 txt(&["ab", "c"]).comparison_key()
862 );
863 }
864
865 #[test]
868 fn all_names_match_and_round_trip() {
869 for (rt, name) in RecordType::ALL.iter().zip(RecordType::ALL_NAMES) {
870 assert_eq!(&rt.as_str(), name, "ALL/ALL_NAMES out of order");
871 assert_eq!(
872 name.parse::<RecordType>().expect("advertised name parses"),
873 *rt
874 );
875 }
876 }
877}