use std::fmt; use std::net::{Ipv4Addr, Ipv6Addr}; use anyhow::{anyhow, bail, Result}; /// An absolute DNS name in canonical form: lowercase, with trailing dot. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Name(String); impl Name { pub fn new(name: &str) -> Self { // In rdata a free-standing "@" survives parsing as a literal first // label; per RFC 1035 it denotes the origin. let name = name.strip_prefix("@.").unwrap_or(name); Name(format!("{}.", name.trim_end_matches('.').to_lowercase())) } pub fn as_str(&self) -> &str { &self.0 } /// The name without its trailing dot, as provider APIs expect. pub fn bare(&self) -> &str { self.0.trim_end_matches('.') } } impl fmt::Display for Name { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } /// Record types this tool understands; anything else at the provider is /// left alone. pub const SUPPORTED_TYPES: [&str; 9] = ["A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SRV", "TXT"]; /// Canonical rdata. Parsing from presentation format normalizes, so that a /// record read from a zone file and the same record fetched from a provider /// compare equal. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum RData { A(Ipv4Addr), Aaaa(Ipv6Addr), Caa { flags: u8, tag: String, value: String, }, Cname(Name), Mx { preference: u16, exchange: Name, }, Ns(Name), Ptr(Name), Srv { priority: u16, weight: u16, port: u16, target: Name, }, Txt(String), } impl RData { pub fn rtype(&self) -> &'static str { match self { RData::A(_) => "A", RData::Aaaa(_) => "AAAA", RData::Caa { .. } => "CAA", RData::Cname(_) => "CNAME", RData::Mx { .. } => "MX", RData::Ns(_) => "NS", RData::Ptr(_) => "PTR", RData::Srv { .. } => "SRV", RData::Txt(_) => "TXT", } } pub fn parse(rtype: &str, rdata: &str) -> Result { let s: String = rdata.split_whitespace().collect::>().join(" "); match rtype { "A" => s .parse() .map(RData::A) .map_err(|_| anyhow!("bad A rdata: {:?}", rdata)), "AAAA" => s .parse() .map(RData::Aaaa) .map_err(|_| anyhow!("bad AAAA rdata: {:?}", rdata)), "CNAME" => Ok(RData::Cname(Name::new(&s))), "NS" => Ok(RData::Ns(Name::new(&s))), "PTR" => Ok(RData::Ptr(Name::new(&s))), "MX" => Self::parse_mx(&s), "SRV" => Self::parse_srv(&s), "CAA" => Self::parse_caa(&s), "TXT" => Ok(RData::Txt(normalize_txt(&s))), _ => Err(anyhow!("{} records are not supported", rtype)), } } fn parse_mx(s: &str) -> Result { let bad = || anyhow!("bad MX rdata: {:?}", s); let (pref, host) = s.split_once(' ').ok_or_else(bad)?; Ok(RData::Mx { preference: pref.parse().map_err(|_| bad())?, exchange: Name::new(host), }) } fn parse_srv(s: &str) -> Result { let bad = || anyhow!("bad SRV rdata: {:?}", s); let fields: Vec<&str> = s.split_whitespace().collect(); let [prio, weight, port, target] = fields.as_slice() else { return Err(bad()); }; Ok(RData::Srv { priority: prio.parse().map_err(|_| bad())?, weight: weight.parse().map_err(|_| bad())?, port: port.parse().map_err(|_| bad())?, target: Name::new(target), }) } fn parse_caa(s: &str) -> Result { let bad = || anyhow!("bad CAA rdata: {:?}", s); let fields: Vec<&str> = s.splitn(3, ' ').collect(); let [flags, tag, value] = fields.as_slice() else { return Err(bad()); }; Ok(RData::Caa { flags: flags.parse().map_err(|_| bad())?, tag: tag.to_string(), value: value.replace('"', ""), }) } } impl fmt::Display for RData { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { RData::A(a) => write!(f, "{a}"), RData::Aaaa(a) => write!(f, "{a}"), RData::Caa { flags, tag, value } => write!(f, "{flags} {tag} {value}"), RData::Cname(n) | RData::Ns(n) | RData::Ptr(n) => write!(f, "{n}"), RData::Mx { preference, exchange, } => write!(f, "{preference} {exchange}"), RData::Srv { priority, weight, port, target, } => { write!(f, "{priority} {weight} {port} {target}") } RData::Txt(s) => f.write_str(s), } } } /// Zone files and APIs chunk TXT values over 255 bytes into adjacent quoted /// strings; compare the concatenated payload. fn normalize_txt(s: &str) -> String { if !s.contains('"') { return s.to_string(); } let mut out = String::new(); let mut in_quote = false; let mut chars = s.chars(); while let Some(c) = chars.next() { match c { '"' => in_quote = !in_quote, // Keep escapes verbatim so both sides stay comparable. '\\' => { out.push(c); out.extend(chars.next()); } c if in_quote => out.push(c), _ => {} } } out } /// A DNS record in canonical form. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Record { pub name: Name, pub ttl: u32, pub data: RData, } impl Record { pub fn parse(name: &str, rtype: &str, ttl: u32, rdata: &str) -> Result { Ok(Record { name: Name::new(name), ttl, data: RData::parse(rtype, rdata)?, }) } pub fn rtype(&self) -> &'static str { self.data.rtype() } /// Two records with equal keys are the same record for sync purposes; /// only the TTL may legitimately differ. pub fn key(&self) -> (&Name, &'static str, &RData) { (&self.name, self.data.rtype(), &self.data) } /// Apex NS records belong to the provider's nameservers and are /// untouchable. pub fn is_apex_ns(&self, apex: &Name) -> bool { self.rtype() == "NS" && self.name == *apex } } impl fmt::Display for Record { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{} {} {} {}", self.name, self.ttl, self.rtype(), self.data ) } } /// A record at the provider of a type we don't manage; kept only for /// display. #[derive(Debug, Clone)] pub struct ForeignRecord { pub name: String, pub rtype: String, pub ttl: u32, pub content: String, } /// A zone: its name (the apex) and the records it contains. #[derive(Debug, Clone)] pub struct Zone { pub name: Name, pub records: Vec, } impl Zone { /// Strip apex NS records, sort into canonical order, and remove exact /// duplicates. An RRset (same name and type) must share one TTL. pub fn normalize(&mut self) -> Result<()> { self.records.retain(|r| !r.is_apex_ns(&self.name)); self.records.sort_by(|a, b| a.key().cmp(&b.key())); self.records.dedup(); for pair in self.records.windows(2) { let [a, b] = pair else { continue }; if a.name == b.name && a.rtype() == b.rtype() && a.ttl != b.ttl { bail!( "{} {} records must share one TTL (found {} and {})", a.name, a.rtype(), a.ttl, b.ttl ); } } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn apex_ns_is_detected() { let apex = Name::new("example.com"); let apex_ns = Record::parse("example.com", "NS", 3600, "ns1.example.com").unwrap(); let non_apex_ns = Record::parse("sub.example.com", "NS", 3600, "ns1.example.com").unwrap(); assert!(apex_ns.is_apex_ns(&apex)); assert!(!non_apex_ns.is_apex_ns(&apex)); } #[test] fn normalize_strips_apex_ns_and_validates_ttls() { let mut zone = Zone { name: Name::new("example.com"), records: vec![ Record::parse("example.com", "NS", 3600, "ns1.example.com").unwrap(), Record::parse("www.example.com", "A", 300, "1.2.3.4").unwrap(), Record::parse("www.example.com", "A", 300, "1.2.3.4").unwrap(), // dup ], }; zone.normalize().unwrap(); assert_eq!(zone.records.len(), 1); assert!( !zone .records .iter() .any(|r| r.rtype() == "NS" && r.name == zone.name) ); } #[test] fn normalize_rejects_mismatched_ttls_in_rrset() { let mut zone = Zone { name: Name::new("example.com"), records: vec![ Record::parse("www.example.com", "A", 300, "1.2.3.4").unwrap(), Record::parse("www.example.com", "A", 600, "1.2.3.4").unwrap(), ], }; assert!(zone.normalize().is_err()); } #[test] fn names_are_absolute_and_lowercase() { assert_eq!(Name::new("WWW.Example.COM").as_str(), "www.example.com."); assert_eq!(Name::new("www.example.com.").as_str(), "www.example.com."); } #[test] fn at_label_denotes_origin() { assert_eq!(Name::new("@.example.com.").as_str(), "example.com."); } #[test] fn txt_quotes_are_stripped() { let a = Record::parse("x", "TXT", 300, "\"v=spf1 -all\"").unwrap(); let b = Record::parse("x", "TXT", 300, "v=spf1 -all").unwrap(); assert_eq!(a, b); } #[test] fn long_txt_chunks_compare_equal_to_single_string() { let chunked = Record::parse("x", "TXT", 300, "\"aaa\" \"bbb\"").unwrap(); let single = Record::parse("x", "TXT", 300, "aaabbb").unwrap(); assert_eq!(chunked, single); } #[test] fn ipv6_is_compressed() { let a = Record::parse("x", "AAAA", 300, "2001:0DB8:0000:0000:0000:0000:0000:0001").unwrap(); assert_eq!(a.data.to_string(), "2001:db8::1"); } #[test] fn mx_target_is_normalized() { let a = Record::parse("x", "MX", 300, "10 Mail.Example.com").unwrap(); assert_eq!(a.data.to_string(), "10 mail.example.com."); } #[test] fn malformed_rdata_is_an_error() { assert!(Record::parse("x", "MX", 300, "not-a-preference mail").is_err()); assert!(Record::parse("x", "A", 300, "999.1.1.1").is_err()); assert!(Record::parse("x", "SRV", 300, "0 5 5060").is_err()); } }