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    TLSA,
24    SSHFP,
25    ANY,
26}
27
28impl RecordType {
29    /// Every queryable record type, in the canonical order the CLI help,
30    /// REPL tab-completion, and the MCP tool schema all present.
31    ///
32    /// Single source of truth: those surfaces used to hand-mirror this list
33    /// and two of the three had drifted (NAPTR/TLSA/SSHFP were missing from
34    /// the REPL completer and the MCP schema, so the types were queryable but
35    /// undiscoverable). Render from this — never re-type the list.
36    pub const ALL: &'static [RecordType] = &[
37        RecordType::A,
38        RecordType::AAAA,
39        RecordType::CNAME,
40        RecordType::MX,
41        RecordType::NS,
42        RecordType::TXT,
43        RecordType::SOA,
44        RecordType::PTR,
45        RecordType::SRV,
46        RecordType::CAA,
47        RecordType::NAPTR,
48        RecordType::DNSKEY,
49        RecordType::DS,
50        RecordType::TLSA,
51        RecordType::SSHFP,
52        RecordType::ANY,
53    ];
54
55    /// The same list as `&'static str` names, for surfaces that need string
56    /// slices directly (completion candidates, help text, JSON schemas).
57    pub const ALL_NAMES: &'static [&'static str] = &[
58        "A", "AAAA", "CNAME", "MX", "NS", "TXT", "SOA", "PTR", "SRV", "CAA", "NAPTR", "DNSKEY",
59        "DS", "TLSA", "SSHFP", "ANY",
60    ];
61
62    /// The canonical uppercase name. Exhaustive match, so adding a variant
63    /// fails to compile here first — the prompt to also extend [`Self::ALL`].
64    pub const fn as_str(&self) -> &'static str {
65        match self {
66            RecordType::A => "A",
67            RecordType::AAAA => "AAAA",
68            RecordType::CNAME => "CNAME",
69            RecordType::MX => "MX",
70            RecordType::NS => "NS",
71            RecordType::TXT => "TXT",
72            RecordType::SOA => "SOA",
73            RecordType::PTR => "PTR",
74            RecordType::SRV => "SRV",
75            RecordType::CAA => "CAA",
76            RecordType::NAPTR => "NAPTR",
77            RecordType::DNSKEY => "DNSKEY",
78            RecordType::DS => "DS",
79            RecordType::TLSA => "TLSA",
80            RecordType::SSHFP => "SSHFP",
81            RecordType::ANY => "ANY",
82        }
83    }
84}
85
86impl fmt::Display for RecordType {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.write_str(self.as_str())
89    }
90}
91
92impl FromStr for RecordType {
93    type Err = SeerError;
94
95    fn from_str(s: &str) -> Result<Self> {
96        match s.to_uppercase().as_str() {
97            "A" => Ok(RecordType::A),
98            "AAAA" => Ok(RecordType::AAAA),
99            "CNAME" => Ok(RecordType::CNAME),
100            "MX" => Ok(RecordType::MX),
101            "NS" => Ok(RecordType::NS),
102            "TXT" => Ok(RecordType::TXT),
103            "SOA" => Ok(RecordType::SOA),
104            "PTR" => Ok(RecordType::PTR),
105            "SRV" => Ok(RecordType::SRV),
106            "CAA" => Ok(RecordType::CAA),
107            "NAPTR" => Ok(RecordType::NAPTR),
108            "DNSKEY" => Ok(RecordType::DNSKEY),
109            "DS" => Ok(RecordType::DS),
110            "TLSA" => Ok(RecordType::TLSA),
111            "SSHFP" => Ok(RecordType::SSHFP),
112            "ANY" | "*" => Ok(RecordType::ANY),
113            _ => Err(SeerError::InvalidRecordType(s.to_string())),
114        }
115    }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct DnsRecord {
120    pub name: String,
121    pub record_type: RecordType,
122    pub ttl: u32,
123    pub data: RecordData,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(tag = "record_type", content = "value", rename_all = "UPPERCASE")]
128#[allow(clippy::upper_case_acronyms)]
129pub enum RecordData {
130    A {
131        address: String,
132    },
133    AAAA {
134        address: String,
135    },
136    CNAME {
137        target: String,
138    },
139    MX {
140        preference: u16,
141        exchange: String,
142    },
143    NS {
144        nameserver: String,
145    },
146    TXT {
147        text: String,
148    },
149    SOA {
150        mname: String,
151        rname: String,
152        serial: u32,
153        refresh: u32,
154        retry: u32,
155        expire: u32,
156        minimum: u32,
157    },
158    PTR {
159        target: String,
160    },
161    SRV {
162        priority: u16,
163        weight: u16,
164        port: u16,
165        target: String,
166    },
167    CAA {
168        flags: u8,
169        tag: String,
170        value: String,
171    },
172    DNSKEY {
173        flags: u16,
174        protocol: u8,
175        algorithm: u8,
176        public_key: String,
177    },
178    DS {
179        key_tag: u16,
180        algorithm: u8,
181        digest_type: u8,
182        digest: String,
183    },
184    TLSA {
185        cert_usage: u8,
186        selector: u8,
187        matching: u8,
188        /// Hex-encoded certificate association data (uppercase).
189        cert_data: String,
190    },
191    SSHFP {
192        algorithm: u8,
193        fingerprint_type: u8,
194        /// Hex-encoded fingerprint (uppercase).
195        fingerprint: String,
196    },
197    NAPTR {
198        order: u16,
199        preference: u16,
200        flags: String,
201        services: String,
202        regexp: String,
203        replacement: String,
204    },
205    Unknown {
206        raw: String,
207    },
208}
209
210impl fmt::Display for RecordData {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        match self {
213            RecordData::A { address } => write!(f, "{}", address),
214            RecordData::AAAA { address } => write!(f, "{}", address),
215            RecordData::CNAME { target } => write!(f, "{}", target),
216            RecordData::MX {
217                preference,
218                exchange,
219            } => write!(f, "{} {}", preference, exchange),
220            RecordData::NS { nameserver } => write!(f, "{}", nameserver),
221            RecordData::TXT { text } => write!(f, "\"{}\"", text),
222            RecordData::SOA {
223                mname,
224                rname,
225                serial,
226                refresh,
227                retry,
228                expire,
229                minimum,
230            } => write!(
231                f,
232                "{} {} {} {} {} {} {}",
233                mname, rname, serial, refresh, retry, expire, minimum
234            ),
235            RecordData::PTR { target } => write!(f, "{}", target),
236            RecordData::SRV {
237                priority,
238                weight,
239                port,
240                target,
241            } => write!(f, "{} {} {} {}", priority, weight, port, target),
242            RecordData::CAA { flags, tag, value } => write!(f, "{} {} \"{}\"", flags, tag, value),
243            RecordData::DNSKEY {
244                flags,
245                protocol,
246                algorithm,
247                public_key,
248            } => write!(f, "{} {} {} {}", flags, protocol, algorithm, public_key),
249            RecordData::DS {
250                key_tag,
251                algorithm,
252                digest_type,
253                digest,
254            } => write!(f, "{} {} {} {}", key_tag, algorithm, digest_type, digest),
255            RecordData::TLSA {
256                cert_usage,
257                selector,
258                matching,
259                cert_data,
260            } => write!(f, "{} {} {} {}", cert_usage, selector, matching, cert_data),
261            RecordData::SSHFP {
262                algorithm,
263                fingerprint_type,
264                fingerprint,
265            } => write!(f, "{} {} {}", algorithm, fingerprint_type, fingerprint),
266            RecordData::NAPTR {
267                order,
268                preference,
269                flags,
270                services,
271                regexp,
272                replacement,
273            } => write!(
274                f,
275                "{} {} \"{}\" \"{}\" \"{}\" {}",
276                order, preference, flags, services, regexp, replacement
277            ),
278            RecordData::Unknown { raw } => write!(f, "{}", raw),
279        }
280    }
281}
282
283impl RecordData {
284    /// The address of an A or AAAA record; `None` for any other type.
285    pub(crate) fn address(&self) -> Option<&str> {
286        match self {
287            RecordData::A { address } | RecordData::AAAA { address } => Some(address),
288            _ => None,
289        }
290    }
291
292    /// The record's value as an equality key for cross-server / cross-time
293    /// comparison (compare, follow, propagation).
294    ///
295    /// Domain-name fields are ASCII-lowercased — DNS names compare
296    /// case-insensitively (RFC 4343), and resolvers applying 0x20 query-name
297    /// randomization return `NS1.EXAMPLE.COM.` and `ns1.example.com.` for the
298    /// same record. Everything else is kept verbatim because it is
299    /// case-SENSITIVE data: TXT strings, base64 DNSKEY key material, CAA
300    /// values, NAPTR regexps. Folding those (as compare/follow used to) hid
301    /// real changes; folding nothing (as propagation used to) reported
302    /// spurious NS/CNAME inconsistencies. This is the one shared rule.
303    ///
304    /// The key is rendered through `Display` so its shape always matches the
305    /// displayed value.
306    pub(crate) fn comparison_key(&self) -> String {
307        let mut folded = self.clone();
308        match &mut folded {
309            RecordData::CNAME { target }
310            | RecordData::PTR { target }
311            | RecordData::SRV { target, .. } => target.make_ascii_lowercase(),
312            RecordData::NS { nameserver } => nameserver.make_ascii_lowercase(),
313            RecordData::MX { exchange, .. } => exchange.make_ascii_lowercase(),
314            RecordData::SOA { mname, rname, .. } => {
315                mname.make_ascii_lowercase();
316                rname.make_ascii_lowercase();
317            }
318            RecordData::NAPTR { replacement, .. } => replacement.make_ascii_lowercase(),
319            // RFC 8659 §4.1: property tags match case-insensitively; the
320            // value is left alone.
321            RecordData::CAA { tag, .. } => tag.make_ascii_lowercase(),
322            RecordData::A { .. }
323            | RecordData::AAAA { .. }
324            | RecordData::TXT { .. }
325            | RecordData::DNSKEY { .. }
326            | RecordData::DS { .. }
327            | RecordData::TLSA { .. }
328            | RecordData::SSHFP { .. }
329            | RecordData::Unknown { .. } => {}
330        }
331        folded.to_string()
332    }
333}
334
335impl DnsRecord {
336    pub fn format_short(&self) -> String {
337        format!("{}", self.data)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_record_type_from_str() {
347        assert_eq!("A".parse::<RecordType>().unwrap(), RecordType::A);
348        assert_eq!("aaaa".parse::<RecordType>().unwrap(), RecordType::AAAA);
349        assert_eq!("MX".parse::<RecordType>().unwrap(), RecordType::MX);
350        assert_eq!("*".parse::<RecordType>().unwrap(), RecordType::ANY);
351        assert!("INVALID".parse::<RecordType>().is_err());
352    }
353
354    #[test]
355    fn test_record_type_display() {
356        assert_eq!(RecordType::A.to_string(), "A");
357        assert_eq!(RecordType::AAAA.to_string(), "AAAA");
358        assert_eq!(RecordType::MX.to_string(), "MX");
359        assert_eq!(RecordType::SOA.to_string(), "SOA");
360    }
361
362    #[test]
363    fn test_dns_record_format_short() {
364        let record = DnsRecord {
365            name: "example.com".to_string(),
366            record_type: RecordType::A,
367            ttl: 300,
368            data: RecordData::A {
369                address: "1.2.3.4".to_string(),
370            },
371        };
372        assert_eq!(record.format_short(), "1.2.3.4");
373    }
374
375    #[test]
376    fn test_record_data_display() {
377        let mx = RecordData::MX {
378            preference: 10,
379            exchange: "mail.example.com".to_string(),
380        };
381        assert_eq!(format!("{}", mx), "10 mail.example.com");
382
383        let txt = RecordData::TXT {
384            text: "v=spf1 include:example.com".to_string(),
385        };
386        assert_eq!(format!("{}", txt), "\"v=spf1 include:example.com\"");
387
388        let srv = RecordData::SRV {
389            priority: 10,
390            weight: 5,
391            port: 443,
392            target: "server.example.com".to_string(),
393        };
394        assert_eq!(format!("{}", srv), "10 5 443 server.example.com");
395    }
396
397    #[test]
398    fn test_record_serialization_roundtrip() {
399        let record = DnsRecord {
400            name: "example.com".to_string(),
401            record_type: RecordType::A,
402            ttl: 300,
403            data: RecordData::A {
404                address: "1.2.3.4".to_string(),
405            },
406        };
407        let json = serde_json::to_string(&record).unwrap();
408        assert!(json.contains("\"A\""));
409        assert!(json.contains("1.2.3.4"));
410    }
411
412    #[test]
413    fn test_soa_display() {
414        let soa = RecordData::SOA {
415            mname: "ns1.example.com".to_string(),
416            rname: "admin.example.com".to_string(),
417            serial: 2024010101,
418            refresh: 3600,
419            retry: 900,
420            expire: 604800,
421            minimum: 86400,
422        };
423        let display = format!("{}", soa);
424        assert!(display.contains("ns1.example.com"));
425        assert!(display.contains("2024010101"));
426    }
427
428    #[test]
429    fn test_naptr_display() {
430        // dig-style: order preference "flags" "services" "regexp" replacement
431        let naptr = RecordData::NAPTR {
432            order: 100,
433            preference: 50,
434            flags: "s".to_string(),
435            services: "http+N2L+N2C+N2R".to_string(),
436            regexp: String::new(),
437            replacement: "www.example.com.".to_string(),
438        };
439        assert_eq!(
440            format!("{}", naptr),
441            "100 50 \"s\" \"http+N2L+N2C+N2R\" \"\" www.example.com."
442        );
443    }
444
445    #[test]
446    fn comparison_key_folds_only_domain_name_fields() {
447        let ns = |n: &str| RecordData::NS {
448            nameserver: n.to_string(),
449        };
450        assert_eq!(
451            ns("NS1.Example.COM.").comparison_key(),
452            ns("ns1.example.com.").comparison_key()
453        );
454        let mx = RecordData::MX {
455            preference: 10,
456            exchange: "MX.Example.com.".to_string(),
457        };
458        assert_eq!(mx.comparison_key(), "10 mx.example.com.");
459        let cname = RecordData::CNAME {
460            target: "Edge.CDN.test.".to_string(),
461        };
462        assert_eq!(cname.comparison_key(), "edge.cdn.test.");
463
464        // Case-sensitive payloads are compared verbatim: a TXT token or a
465        // base64 key that changes only in case IS a different value.
466        let txt = |t: &str| RecordData::TXT {
467            text: t.to_string(),
468        };
469        assert_ne!(
470            txt("verify=AbC").comparison_key(),
471            txt("verify=abc").comparison_key()
472        );
473        let key = |k: &str| RecordData::DNSKEY {
474            flags: 257,
475            protocol: 3,
476            algorithm: 13,
477            public_key: k.to_string(),
478        };
479        assert_ne!(
480            key("mdsswUyr3DPW").comparison_key(),
481            key("MDSSWUYR3DPW").comparison_key()
482        );
483    }
484
485    /// Drift guard for the three surfaces that render `RecordType::ALL`
486    /// (CLI help, REPL completion, MCP tool schema). Adding a variant breaks
487    /// `as_str`'s exhaustive match at compile time; this pins the count so the
488    /// author must also extend `ALL`/`ALL_NAMES` rather than only `as_str`.
489    #[test]
490    fn all_is_complete() {
491        assert_eq!(
492            RecordType::ALL.len(),
493            16,
494            "new RecordType variant — add it to ALL and ALL_NAMES too"
495        );
496        assert_eq!(RecordType::ALL.len(), RecordType::ALL_NAMES.len());
497    }
498
499    /// `ALL`, `ALL_NAMES`, `as_str`, and `FromStr` must agree entry-for-entry:
500    /// a name a surface advertises has to be one `from_str` actually accepts.
501    #[test]
502    fn all_names_match_and_round_trip() {
503        for (rt, name) in RecordType::ALL.iter().zip(RecordType::ALL_NAMES) {
504            assert_eq!(&rt.as_str(), name, "ALL/ALL_NAMES out of order");
505            assert_eq!(
506                name.parse::<RecordType>().expect("advertised name parses"),
507                *rt
508            );
509        }
510    }
511}