Skip to main content

seer_core/dns/
records.rs

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    /// Every queryable record type, in the canonical order the CLI help,
34    /// REPL tab-completion, and the MCP tool schema all present.
35    ///
36    /// Single source of truth: those surfaces used to hand-mirror this list
37    /// and two of the three had drifted (NAPTR/TLSA/SSHFP were missing from
38    /// the REPL completer and the MCP schema, so the types were queryable but
39    /// undiscoverable). Render from this — never re-type the list.
40    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    /// The same list as `&'static str` names, for surfaces that need string
64    /// slices directly (completion candidates, help text, JSON schemas).
65    /// Derived from [`Self::ALL`] and [`Self::as_str`], so it cannot drift.
66    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    /// The canonical uppercase name. Exhaustive match, so adding a variant
77    /// fails to compile here first — the prompt to also extend [`Self::ALL`].
78    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    /// Parses a type name as [`RecordType::as_str`] spells it, ASCII
114    /// case-insensitively, or `*` for `ANY` (dig's spelling).
115    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/// One SvcParam of an HTTPS or SVCB record (RFC 9460), in presentation form.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct SvcParam {
138    /// The RFC 9460 presentation name: `mandatory`, `alpn`,
139    /// `no-default-alpn`, `port`, `ipv4hint`, `ech`, `ipv6hint`, or
140    /// `key<N>` for a key without a registered name.
141    pub key: String,
142    /// The presentation value without surrounding quotes: a comma-separated
143    /// list for list-valued keys (`h3,h2`, `192.0.2.1,192.0.2.2`), base64 for
144    /// `ech`, and empty for a valueless key such as `no-default-alpn`.
145    pub value: String,
146}
147
148impl fmt::Display for SvcParam {
149    /// `key=value` as dig prints it: an `alpn` list is quoted, and a
150    /// valueless key stands bare.
151    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
162/// Writes HTTPS/SVCB RDATA the way dig prints it: priority, target name,
163/// then each SvcParam (`1 . alpn="h3,h2" ipv4hint=192.0.2.1`). AliasMode
164/// (priority 0, no params) reads `0 target.`.
165fn 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
178/// Writes TXT RDATA the way dig prints it: each character-string quoted
179/// ([`quote_char_string`]), separated by a space — so the string boundaries,
180/// and a `"` inside a string, read unambiguously.
181fn 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(&quote_char_string(string))?;
193    }
194    Ok(())
195}
196
197/// A character-string in quoted presentation form (RFC 1035 §5.1): `"` and
198/// `\` backslash-escaped, everything else as is — control characters are
199/// left to the renderers' terminal/markdown sanitizers, as for every other
200/// remote string.
201fn 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    /// A TXT record: one or more character-strings (RFC 1035 §3.3.14).
235    /// Build one with [`RecordData::txt`], which keeps the two fields in
236    /// step.
237    TXT {
238        /// The character-strings joined with nothing between them — how
239        /// SPF (RFC 7208 §3.3), DMARC and verification tokens read a TXT
240        /// record.
241        text: String,
242        /// The character-strings as the server sent them: where a record
243        /// splits is part of its data, so `["ab", "c"]` and `["a", "bc"]`
244        /// are different records with the same `text`. Empty only for a
245        /// record built without them (data serialized before this field),
246        /// which then reads as the one string `text`.
247        #[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    /// Child DS (RFC 7344): the DS record a child zone asks its parent to
286    /// publish. Same fields as [`RecordData::DS`]; `algorithm` 0 is the
287    /// RFC 8078 delete request.
288    CDS {
289        key_tag: u16,
290        algorithm: u8,
291        digest_type: u8,
292        /// Hex-encoded digest (uppercase).
293        digest: String,
294    },
295    /// Child DNSKEY (RFC 7344). Same fields as [`RecordData::DNSKEY`];
296    /// `algorithm` 0 is the RFC 8078 delete request.
297    CDNSKEY {
298        flags: u16,
299        protocol: u8,
300        algorithm: u8,
301        /// Base64-encoded public key.
302        public_key: String,
303    },
304    TLSA {
305        cert_usage: u8,
306        selector: u8,
307        matching: u8,
308        /// Hex-encoded certificate association data (uppercase).
309        cert_data: String,
310    },
311    SSHFP {
312        algorithm: u8,
313        fingerprint_type: u8,
314        /// Hex-encoded fingerprint (uppercase).
315        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 service binding (RFC 9460). `priority` 0 is AliasMode (`target`
326    /// is an alias, no params); anything else is ServiceMode.
327    HTTPS {
328        priority: u16,
329        /// The TargetName; `.` means the owner name itself.
330        target: String,
331        params: Vec<SvcParam>,
332    },
333    /// General service binding (RFC 9460), same shape as
334    /// [`RecordData::HTTPS`].
335    SVCB {
336        priority: u16,
337        /// The TargetName; `.` means the owner name itself.
338        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    /// A TXT record from its character-strings, in the order sent.
439    pub fn txt(strings: Vec<String>) -> Self {
440        RecordData::TXT {
441            text: strings.concat(),
442            strings,
443        }
444    }
445
446    /// The address of an A or AAAA record; `None` for any other type.
447    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    /// The record's value as an equality key for cross-server / cross-time
455    /// comparison (compare, follow, propagation).
456    ///
457    /// Domain-name fields are ASCII-lowercased — DNS names compare
458    /// case-insensitively (RFC 4343), and resolvers applying 0x20 query-name
459    /// randomization return `NS1.EXAMPLE.COM.` and `ns1.example.com.` for the
460    /// same record. Everything else is kept verbatim because it is
461    /// case-SENSITIVE data: TXT strings, base64 DNSKEY key material, CAA
462    /// values, NAPTR regexps, HTTPS/SVCB params. Folding those (as compare/follow used to) hid
463    /// real changes; folding nothing (as propagation used to) reported
464    /// spurious NS/CNAME inconsistencies. This is the one shared rule.
465    ///
466    /// The key is rendered through `Display` so its shape always matches the
467    /// displayed value.
468    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            // RFC 8659 §4.1: property tags match case-insensitively; the
484            // value is left alone.
485            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        // Regression: Unicode uppercasing folded U+017F (long s) to `S`, so
533        // "ſrv" parsed as SRV.
534        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        // No trim: a padded label is malformed input, not a type.
541        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        // dig-style: order preference "flags" "services" "regexp" replacement
621        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        // Case-sensitive payloads are compared verbatim: a TXT token or a
655        // base64 key that changes only in case IS a different value.
656        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        // Valueless keys stand bare; other values are never quoted.
696        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        // AliasMode: priority 0 and the alias target, nothing else.
707        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        // RFC 8078 delete request.
732        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        // Params are opaque data: compared verbatim.
753        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        // ANY stays last, after the new types.
802        assert_eq!(RecordType::ALL.last(), Some(&RecordType::ANY));
803    }
804
805    /// Drift guard for the three surfaces that render `RecordType::ALL`
806    /// (CLI help, REPL completion, MCP tool schema). Adding a variant breaks
807    /// `as_str`'s exhaustive match at compile time; this pins the count so the
808    /// author must also extend `ALL` (which `ALL_NAMES` and `FromStr` derive
809    /// from) rather than only `as_str`.
810    #[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        // `text` is the concatenation SPF/DMARC readers want.
832        assert!(matches!(
833            &split,
834            RecordData::TXT { text, .. } if text == "v=spf1 include:_spf.example.com -all"
835        ));
836        // Quotes and backslashes are escaped, so one string cannot pass for
837        // two.
838        assert_eq!(txt(&["a\"b", "c\\d"]).to_string(), r#""a\"b" "c\\d""#);
839        assert_eq!(txt(&[""]).to_string(), r#""""#);
840        // Data without `strings` (serialized before the field) reads as one.
841        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        // Regression: the strings were joined bare, so these two different
850        // records compared equal, and `a" "b` read like two strings.
851        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    /// `ALL`, `ALL_NAMES`, `as_str`, and `FromStr` must agree entry-for-entry:
866    /// a name a surface advertises has to be one `from_str` actually accepts.
867    #[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}