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 DnsRecord {
284    pub fn format_short(&self) -> String {
285        format!("{}", self.data)
286    }
287
288    pub fn format_full(&self) -> String {
289        format!(
290            "{}\t{}\tIN\t{}\t{}",
291            self.name, self.ttl, self.record_type, self.data
292        )
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_record_type_from_str() {
302        assert_eq!("A".parse::<RecordType>().unwrap(), RecordType::A);
303        assert_eq!("aaaa".parse::<RecordType>().unwrap(), RecordType::AAAA);
304        assert_eq!("MX".parse::<RecordType>().unwrap(), RecordType::MX);
305        assert_eq!("*".parse::<RecordType>().unwrap(), RecordType::ANY);
306        assert!("INVALID".parse::<RecordType>().is_err());
307    }
308
309    #[test]
310    fn test_record_type_display() {
311        assert_eq!(RecordType::A.to_string(), "A");
312        assert_eq!(RecordType::AAAA.to_string(), "AAAA");
313        assert_eq!(RecordType::MX.to_string(), "MX");
314        assert_eq!(RecordType::SOA.to_string(), "SOA");
315    }
316
317    #[test]
318    fn test_dns_record_format_short() {
319        let record = DnsRecord {
320            name: "example.com".to_string(),
321            record_type: RecordType::A,
322            ttl: 300,
323            data: RecordData::A {
324                address: "1.2.3.4".to_string(),
325            },
326        };
327        assert_eq!(record.format_short(), "1.2.3.4");
328    }
329
330    #[test]
331    fn test_dns_record_format_full() {
332        let record = DnsRecord {
333            name: "example.com".to_string(),
334            record_type: RecordType::A,
335            ttl: 300,
336            data: RecordData::A {
337                address: "1.2.3.4".to_string(),
338            },
339        };
340        assert_eq!(record.format_full(), "example.com\t300\tIN\tA\t1.2.3.4");
341    }
342
343    #[test]
344    fn test_record_data_display() {
345        let mx = RecordData::MX {
346            preference: 10,
347            exchange: "mail.example.com".to_string(),
348        };
349        assert_eq!(format!("{}", mx), "10 mail.example.com");
350
351        let txt = RecordData::TXT {
352            text: "v=spf1 include:example.com".to_string(),
353        };
354        assert_eq!(format!("{}", txt), "\"v=spf1 include:example.com\"");
355
356        let srv = RecordData::SRV {
357            priority: 10,
358            weight: 5,
359            port: 443,
360            target: "server.example.com".to_string(),
361        };
362        assert_eq!(format!("{}", srv), "10 5 443 server.example.com");
363    }
364
365    #[test]
366    fn test_record_serialization_roundtrip() {
367        let record = DnsRecord {
368            name: "example.com".to_string(),
369            record_type: RecordType::A,
370            ttl: 300,
371            data: RecordData::A {
372                address: "1.2.3.4".to_string(),
373            },
374        };
375        let json = serde_json::to_string(&record).unwrap();
376        assert!(json.contains("\"A\""));
377        assert!(json.contains("1.2.3.4"));
378    }
379
380    #[test]
381    fn test_soa_display() {
382        let soa = RecordData::SOA {
383            mname: "ns1.example.com".to_string(),
384            rname: "admin.example.com".to_string(),
385            serial: 2024010101,
386            refresh: 3600,
387            retry: 900,
388            expire: 604800,
389            minimum: 86400,
390        };
391        let display = format!("{}", soa);
392        assert!(display.contains("ns1.example.com"));
393        assert!(display.contains("2024010101"));
394    }
395
396    #[test]
397    fn test_naptr_display() {
398        // dig-style: order preference "flags" "services" "regexp" replacement
399        let naptr = RecordData::NAPTR {
400            order: 100,
401            preference: 50,
402            flags: "s".to_string(),
403            services: "http+N2L+N2C+N2R".to_string(),
404            regexp: String::new(),
405            replacement: "www.example.com.".to_string(),
406        };
407        assert_eq!(
408            format!("{}", naptr),
409            "100 50 \"s\" \"http+N2L+N2C+N2R\" \"\" www.example.com."
410        );
411    }
412
413    /// Drift guard for the three surfaces that render `RecordType::ALL`
414    /// (CLI help, REPL completion, MCP tool schema). Adding a variant breaks
415    /// `as_str`'s exhaustive match at compile time; this pins the count so the
416    /// author must also extend `ALL`/`ALL_NAMES` rather than only `as_str`.
417    #[test]
418    fn all_is_complete() {
419        assert_eq!(
420            RecordType::ALL.len(),
421            16,
422            "new RecordType variant — add it to ALL and ALL_NAMES too"
423        );
424        assert_eq!(RecordType::ALL.len(), RecordType::ALL_NAMES.len());
425    }
426
427    /// `ALL`, `ALL_NAMES`, `as_str`, and `FromStr` must agree entry-for-entry:
428    /// a name a surface advertises has to be one `from_str` actually accepts.
429    #[test]
430    fn all_names_match_and_round_trip() {
431        for (rt, name) in RecordType::ALL.iter().zip(RecordType::ALL_NAMES) {
432            assert_eq!(&rt.as_str(), name, "ALL/ALL_NAMES out of order");
433            assert_eq!(
434                name.parse::<RecordType>().expect("advertised name parses"),
435                *rt
436            );
437        }
438    }
439}