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 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 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 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 cert_data: String,
190 },
191 SSHFP {
192 algorithm: u8,
193 fingerprint_type: u8,
194 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 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 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 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 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 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 #[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 #[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}