use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
use crate::error::{Result, SeerError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum RecordType {
A,
AAAA,
CNAME,
MX,
NS,
TXT,
SOA,
PTR,
SRV,
CAA,
NAPTR,
DNSKEY,
DS,
CDS,
CDNSKEY,
TLSA,
SSHFP,
HTTPS,
SVCB,
ANY,
}
impl RecordType {
pub const ALL: &'static [RecordType] = &[
RecordType::A,
RecordType::AAAA,
RecordType::CNAME,
RecordType::MX,
RecordType::NS,
RecordType::TXT,
RecordType::SOA,
RecordType::PTR,
RecordType::SRV,
RecordType::CAA,
RecordType::NAPTR,
RecordType::DNSKEY,
RecordType::DS,
RecordType::CDS,
RecordType::CDNSKEY,
RecordType::TLSA,
RecordType::SSHFP,
RecordType::HTTPS,
RecordType::SVCB,
RecordType::ANY,
];
pub const ALL_NAMES: &'static [&'static str] = &{
let mut names = [""; RecordType::ALL.len()];
let mut i = 0;
while i < names.len() {
names[i] = RecordType::ALL[i].as_str();
i += 1;
}
names
};
pub const fn as_str(&self) -> &'static str {
match self {
RecordType::A => "A",
RecordType::AAAA => "AAAA",
RecordType::CNAME => "CNAME",
RecordType::MX => "MX",
RecordType::NS => "NS",
RecordType::TXT => "TXT",
RecordType::SOA => "SOA",
RecordType::PTR => "PTR",
RecordType::SRV => "SRV",
RecordType::CAA => "CAA",
RecordType::NAPTR => "NAPTR",
RecordType::DNSKEY => "DNSKEY",
RecordType::DS => "DS",
RecordType::CDS => "CDS",
RecordType::CDNSKEY => "CDNSKEY",
RecordType::TLSA => "TLSA",
RecordType::SSHFP => "SSHFP",
RecordType::HTTPS => "HTTPS",
RecordType::SVCB => "SVCB",
RecordType::ANY => "ANY",
}
}
}
impl fmt::Display for RecordType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for RecordType {
type Err = SeerError;
fn from_str(s: &str) -> Result<Self> {
if s == "*" {
return Ok(RecordType::ANY);
}
RecordType::ALL
.iter()
.copied()
.find(|t| t.as_str().eq_ignore_ascii_case(s))
.ok_or_else(|| SeerError::InvalidRecordType(s.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DnsRecord {
pub name: String,
pub record_type: RecordType,
pub ttl: u32,
pub data: RecordData,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SvcParam {
pub key: String,
pub value: String,
}
impl fmt::Display for SvcParam {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.value.is_empty() {
f.write_str(&self.key)
} else if self.key == "alpn" {
write!(f, "{}=\"{}\"", self.key, self.value)
} else {
write!(f, "{}={}", self.key, self.value)
}
}
}
fn write_svcb(
f: &mut fmt::Formatter<'_>,
priority: u16,
target: &str,
params: &[SvcParam],
) -> fmt::Result {
write!(f, "{} {}", priority, target)?;
for param in params {
write!(f, " {}", param)?;
}
Ok(())
}
fn write_txt(f: &mut fmt::Formatter<'_>, text: &str, strings: &[String]) -> fmt::Result {
let whole = [text.to_string()];
let strings = if strings.is_empty() {
&whole[..]
} else {
strings
};
for (i, string) in strings.iter().enumerate() {
if i > 0 {
f.write_str(" ")?;
}
f.write_str("e_char_string(string))?;
}
Ok(())
}
fn quote_char_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
if matches!(c, '"' | '\\') {
out.push('\\');
}
out.push(c);
}
out.push('"');
out
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "record_type", content = "value", rename_all = "UPPERCASE")]
#[allow(clippy::upper_case_acronyms)]
pub enum RecordData {
A {
address: String,
},
AAAA {
address: String,
},
CNAME {
target: String,
},
MX {
preference: u16,
exchange: String,
},
NS {
nameserver: String,
},
TXT {
text: String,
#[serde(default)]
strings: Vec<String>,
},
SOA {
mname: String,
rname: String,
serial: u32,
refresh: u32,
retry: u32,
expire: u32,
minimum: u32,
},
PTR {
target: String,
},
SRV {
priority: u16,
weight: u16,
port: u16,
target: String,
},
CAA {
flags: u8,
tag: String,
value: String,
},
DNSKEY {
flags: u16,
protocol: u8,
algorithm: u8,
public_key: String,
},
DS {
key_tag: u16,
algorithm: u8,
digest_type: u8,
digest: String,
},
CDS {
key_tag: u16,
algorithm: u8,
digest_type: u8,
digest: String,
},
CDNSKEY {
flags: u16,
protocol: u8,
algorithm: u8,
public_key: String,
},
TLSA {
cert_usage: u8,
selector: u8,
matching: u8,
cert_data: String,
},
SSHFP {
algorithm: u8,
fingerprint_type: u8,
fingerprint: String,
},
NAPTR {
order: u16,
preference: u16,
flags: String,
services: String,
regexp: String,
replacement: String,
},
HTTPS {
priority: u16,
target: String,
params: Vec<SvcParam>,
},
SVCB {
priority: u16,
target: String,
params: Vec<SvcParam>,
},
}
impl fmt::Display for RecordData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RecordData::A { address } => write!(f, "{}", address),
RecordData::AAAA { address } => write!(f, "{}", address),
RecordData::CNAME { target } => write!(f, "{}", target),
RecordData::MX {
preference,
exchange,
} => write!(f, "{} {}", preference, exchange),
RecordData::NS { nameserver } => write!(f, "{}", nameserver),
RecordData::TXT { text, strings } => write_txt(f, text, strings),
RecordData::SOA {
mname,
rname,
serial,
refresh,
retry,
expire,
minimum,
} => write!(
f,
"{} {} {} {} {} {} {}",
mname, rname, serial, refresh, retry, expire, minimum
),
RecordData::PTR { target } => write!(f, "{}", target),
RecordData::SRV {
priority,
weight,
port,
target,
} => write!(f, "{} {} {} {}", priority, weight, port, target),
RecordData::CAA { flags, tag, value } => write!(f, "{} {} \"{}\"", flags, tag, value),
RecordData::DNSKEY {
flags,
protocol,
algorithm,
public_key,
} => write!(f, "{} {} {} {}", flags, protocol, algorithm, public_key),
RecordData::DS {
key_tag,
algorithm,
digest_type,
digest,
} => write!(f, "{} {} {} {}", key_tag, algorithm, digest_type, digest),
RecordData::CDS {
key_tag,
algorithm,
digest_type,
digest,
} => write!(f, "{} {} {} {}", key_tag, algorithm, digest_type, digest),
RecordData::CDNSKEY {
flags,
protocol,
algorithm,
public_key,
} => write!(f, "{} {} {} {}", flags, protocol, algorithm, public_key),
RecordData::TLSA {
cert_usage,
selector,
matching,
cert_data,
} => write!(f, "{} {} {} {}", cert_usage, selector, matching, cert_data),
RecordData::SSHFP {
algorithm,
fingerprint_type,
fingerprint,
} => write!(f, "{} {} {}", algorithm, fingerprint_type, fingerprint),
RecordData::NAPTR {
order,
preference,
flags,
services,
regexp,
replacement,
} => write!(
f,
"{} {} \"{}\" \"{}\" \"{}\" {}",
order, preference, flags, services, regexp, replacement
),
RecordData::HTTPS {
priority,
target,
params,
}
| RecordData::SVCB {
priority,
target,
params,
} => write_svcb(f, *priority, target, params),
}
}
}
impl RecordData {
pub fn txt(strings: Vec<String>) -> Self {
RecordData::TXT {
text: strings.concat(),
strings,
}
}
pub(crate) fn address(&self) -> Option<&str> {
match self {
RecordData::A { address } | RecordData::AAAA { address } => Some(address),
_ => None,
}
}
pub(crate) fn comparison_key(&self) -> String {
let mut folded = self.clone();
match &mut folded {
RecordData::CNAME { target }
| RecordData::PTR { target }
| RecordData::SRV { target, .. }
| RecordData::HTTPS { target, .. }
| RecordData::SVCB { target, .. } => target.make_ascii_lowercase(),
RecordData::NS { nameserver } => nameserver.make_ascii_lowercase(),
RecordData::MX { exchange, .. } => exchange.make_ascii_lowercase(),
RecordData::SOA { mname, rname, .. } => {
mname.make_ascii_lowercase();
rname.make_ascii_lowercase();
}
RecordData::NAPTR { replacement, .. } => replacement.make_ascii_lowercase(),
RecordData::CAA { tag, .. } => tag.make_ascii_lowercase(),
RecordData::A { .. }
| RecordData::AAAA { .. }
| RecordData::TXT { .. }
| RecordData::DNSKEY { .. }
| RecordData::DS { .. }
| RecordData::CDS { .. }
| RecordData::CDNSKEY { .. }
| RecordData::TLSA { .. }
| RecordData::SSHFP { .. } => {}
}
folded.to_string()
}
}
impl DnsRecord {
pub fn format_short(&self) -> String {
format!("{}", self.data)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_record_type_from_str() {
assert_eq!("A".parse::<RecordType>().unwrap(), RecordType::A);
assert_eq!("aaaa".parse::<RecordType>().unwrap(), RecordType::AAAA);
assert_eq!("MX".parse::<RecordType>().unwrap(), RecordType::MX);
assert_eq!("*".parse::<RecordType>().unwrap(), RecordType::ANY);
assert!("INVALID".parse::<RecordType>().is_err());
}
#[test]
fn record_type_from_str_is_ascii_case_insensitive() {
for (name, expected) in [
("a", RecordType::A),
("mx", RecordType::MX),
("Mx", RecordType::MX),
("cNaMe", RecordType::CNAME),
("dnskey", RecordType::DNSKEY),
("any", RecordType::ANY),
("ANY", RecordType::ANY),
] {
assert_eq!(name.parse::<RecordType>().unwrap(), expected, "{name}");
}
assert!("ſrv".parse::<RecordType>().is_err());
assert!("ſoa".parse::<RecordType>().is_err());
}
#[test]
fn record_type_from_str_rejects_padding_and_unknown_names() {
for bad in [" A", "A ", "\tA\n", "NOTAREAL", "A1", "", "**"] {
assert!(bad.parse::<RecordType>().is_err(), "{bad:?}");
}
}
#[test]
fn test_record_type_display() {
assert_eq!(RecordType::A.to_string(), "A");
assert_eq!(RecordType::AAAA.to_string(), "AAAA");
assert_eq!(RecordType::MX.to_string(), "MX");
assert_eq!(RecordType::SOA.to_string(), "SOA");
}
#[test]
fn test_dns_record_format_short() {
let record = DnsRecord {
name: "example.com".to_string(),
record_type: RecordType::A,
ttl: 300,
data: RecordData::A {
address: "1.2.3.4".to_string(),
},
};
assert_eq!(record.format_short(), "1.2.3.4");
}
#[test]
fn test_record_data_display() {
let mx = RecordData::MX {
preference: 10,
exchange: "mail.example.com".to_string(),
};
assert_eq!(format!("{}", mx), "10 mail.example.com");
let txt = RecordData::txt(vec!["v=spf1 include:example.com".to_string()]);
assert_eq!(format!("{}", txt), "\"v=spf1 include:example.com\"");
let srv = RecordData::SRV {
priority: 10,
weight: 5,
port: 443,
target: "server.example.com".to_string(),
};
assert_eq!(format!("{}", srv), "10 5 443 server.example.com");
}
#[test]
fn test_record_serialization_roundtrip() {
let record = DnsRecord {
name: "example.com".to_string(),
record_type: RecordType::A,
ttl: 300,
data: RecordData::A {
address: "1.2.3.4".to_string(),
},
};
let json = serde_json::to_string(&record).unwrap();
assert!(json.contains("\"A\""));
assert!(json.contains("1.2.3.4"));
}
#[test]
fn test_soa_display() {
let soa = RecordData::SOA {
mname: "ns1.example.com".to_string(),
rname: "admin.example.com".to_string(),
serial: 2024010101,
refresh: 3600,
retry: 900,
expire: 604800,
minimum: 86400,
};
let display = format!("{}", soa);
assert!(display.contains("ns1.example.com"));
assert!(display.contains("2024010101"));
}
#[test]
fn test_naptr_display() {
let naptr = RecordData::NAPTR {
order: 100,
preference: 50,
flags: "s".to_string(),
services: "http+N2L+N2C+N2R".to_string(),
regexp: String::new(),
replacement: "www.example.com.".to_string(),
};
assert_eq!(
format!("{}", naptr),
"100 50 \"s\" \"http+N2L+N2C+N2R\" \"\" www.example.com."
);
}
#[test]
fn comparison_key_folds_only_domain_name_fields() {
let ns = |n: &str| RecordData::NS {
nameserver: n.to_string(),
};
assert_eq!(
ns("NS1.Example.COM.").comparison_key(),
ns("ns1.example.com.").comparison_key()
);
let mx = RecordData::MX {
preference: 10,
exchange: "MX.Example.com.".to_string(),
};
assert_eq!(mx.comparison_key(), "10 mx.example.com.");
let cname = RecordData::CNAME {
target: "Edge.CDN.test.".to_string(),
};
assert_eq!(cname.comparison_key(), "edge.cdn.test.");
let txt = |t: &str| RecordData::txt(vec![t.to_string()]);
assert_ne!(
txt("verify=AbC").comparison_key(),
txt("verify=abc").comparison_key()
);
let key = |k: &str| RecordData::DNSKEY {
flags: 257,
protocol: 3,
algorithm: 13,
public_key: k.to_string(),
};
assert_ne!(
key("mdsswUyr3DPW").comparison_key(),
key("MDSSWUYR3DPW").comparison_key()
);
}
fn svc(key: &str, value: &str) -> SvcParam {
SvcParam {
key: key.to_string(),
value: value.to_string(),
}
}
#[test]
fn https_display_mirrors_dig() {
let https = RecordData::HTTPS {
priority: 1,
target: ".".to_string(),
params: vec![
svc("alpn", "h3,h2"),
svc("ipv4hint", "104.16.132.229,104.16.133.229"),
],
};
assert_eq!(
https.to_string(),
"1 . alpn=\"h3,h2\" ipv4hint=104.16.132.229,104.16.133.229"
);
let svcb = RecordData::SVCB {
priority: 2,
target: "svc.example.net.".to_string(),
params: vec![svc("no-default-alpn", ""), svc("port", "8443")],
};
assert_eq!(
svcb.to_string(),
"2 svc.example.net. no-default-alpn port=8443"
);
let alias = RecordData::HTTPS {
priority: 0,
target: "pool.example.net.".to_string(),
params: vec![],
};
assert_eq!(alias.to_string(), "0 pool.example.net.");
}
#[test]
fn child_dnssec_records_display_like_their_parents() {
let cds = RecordData::CDS {
key_tag: 2371,
algorithm: 13,
digest_type: 2,
digest: "ABCDEF01".to_string(),
};
assert_eq!(cds.to_string(), "2371 13 2 ABCDEF01");
let cdnskey = RecordData::CDNSKEY {
flags: 257,
protocol: 3,
algorithm: 13,
public_key: "mdsswUyr3DPW".to_string(),
};
assert_eq!(cdnskey.to_string(), "257 3 13 mdsswUyr3DPW");
let delete = RecordData::CDNSKEY {
flags: 0,
protocol: 3,
algorithm: 0,
public_key: "AA==".to_string(),
};
assert_eq!(delete.to_string(), "0 3 0 AA==");
}
#[test]
fn comparison_key_folds_the_service_target_only() {
let https = |target: &str, alpn: &str| RecordData::HTTPS {
priority: 1,
target: target.to_string(),
params: vec![svc("alpn", alpn)],
};
assert_eq!(
https("Svc.Example.NET.", "h2").comparison_key(),
https("svc.example.net.", "h2").comparison_key()
);
assert_ne!(
https(".", "H2").comparison_key(),
https(".", "h2").comparison_key()
);
let cds = |digest: &str| RecordData::CDS {
key_tag: 1,
algorithm: 13,
digest_type: 2,
digest: digest.to_string(),
};
assert_ne!(cds("ABCD").comparison_key(), cds("abcd").comparison_key());
}
#[test]
fn https_serializes_with_structured_params() {
let record = DnsRecord {
name: "example.com".to_string(),
record_type: RecordType::HTTPS,
ttl: 300,
data: RecordData::HTTPS {
priority: 1,
target: ".".to_string(),
params: vec![svc("alpn", "h2")],
},
};
let json = serde_json::to_value(&record).unwrap();
assert_eq!(json["record_type"], "HTTPS");
assert_eq!(json["data"]["record_type"], "HTTPS");
assert_eq!(json["data"]["value"]["priority"], 1);
assert_eq!(json["data"]["value"]["target"], ".");
assert_eq!(
json["data"]["value"]["params"],
serde_json::json!([{"key": "alpn", "value": "h2"}])
);
let back: DnsRecord = serde_json::from_value(json).unwrap();
assert_eq!(back, record);
}
#[test]
fn new_record_types_parse_by_name() {
for (name, expected) in [
("https", RecordType::HTTPS),
("SVCB", RecordType::SVCB),
("cds", RecordType::CDS),
("CDNSKEY", RecordType::CDNSKEY),
] {
assert_eq!(name.parse::<RecordType>().unwrap(), expected);
}
assert_eq!(RecordType::ALL.last(), Some(&RecordType::ANY));
}
#[test]
fn all_is_complete() {
assert_eq!(
RecordType::ALL.len(),
20,
"new RecordType variant — add it to ALL too"
);
assert_eq!(RecordType::ALL.len(), RecordType::ALL_NAMES.len());
}
fn txt(strings: &[&str]) -> RecordData {
RecordData::txt(strings.iter().map(|s| s.to_string()).collect())
}
#[test]
fn txt_displays_each_string_quoted_and_escaped_like_dig() {
let split = txt(&["v=spf1 include:_spf.example.com", " -all"]);
assert_eq!(
split.to_string(),
r#""v=spf1 include:_spf.example.com" " -all""#
);
assert!(matches!(
&split,
RecordData::TXT { text, .. } if text == "v=spf1 include:_spf.example.com -all"
));
assert_eq!(txt(&["a\"b", "c\\d"]).to_string(), r#""a\"b" "c\\d""#);
assert_eq!(txt(&[""]).to_string(), r#""""#);
let legacy: RecordData =
serde_json::from_str(r#"{"record_type":"TXT","value":{"text":"a b"}}"#).unwrap();
assert_eq!(legacy.to_string(), r#""a b""#);
assert_eq!(legacy.comparison_key(), txt(&["a b"]).comparison_key());
}
#[test]
fn txt_comparison_key_keeps_string_boundaries() {
assert_ne!(
txt(&["ab", "c"]).comparison_key(),
txt(&["a", "bc"]).comparison_key()
);
assert_ne!(
txt(&["a\" \"b"]).comparison_key(),
txt(&["a", "b"]).comparison_key()
);
assert_eq!(
txt(&["ab", "c"]).comparison_key(),
txt(&["ab", "c"]).comparison_key()
);
}
#[test]
fn all_names_match_and_round_trip() {
for (rt, name) in RecordType::ALL.iter().zip(RecordType::ALL_NAMES) {
assert_eq!(&rt.as_str(), name, "ALL/ALL_NAMES out of order");
assert_eq!(
name.parse::<RecordType>().expect("advertised name parses"),
*rt
);
}
}
}