use std::path::Path; use anyhow::{Context, Result, anyhow, bail}; use domain::rdata::ZoneRecordData; use domain::zonefile::inplace::{Entry, Zonefile}; use crate::dns::{ForeignRecord, Name, RData, Record, Zone}; /// Load a zone file, returning the zone name and its records in canonical form. /// /// The zone name comes from `$ORIGIN` if the file sets one, otherwise from /// the file name (`example.com.zone` -> `example.com`). pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).with_context(|| path.display().to_string())?; let origin = find_origin(&text) .or_else(|| { path.file_stem() .and_then(|s| s.to_str()) .map(str::to_string) }) .ok_or_else(|| anyhow!("{}: cannot determine zone name", path.display()))?; Ok(Zone { name: Name::new(&origin), records: parse(&text, &origin)?, }) } fn find_origin(text: &str) -> Option { for line in text.lines() { let line = line.split(';').next().unwrap_or("").trim(); let mut words = line.split_whitespace(); if words .next() .is_some_and(|w| w.eq_ignore_ascii_case("$ORIGIN")) { return words.next().map(|n| n.trim_end_matches('.').to_string()); } } None } fn parse(text: &str, origin: &str) -> Result> { // Prepending an absolute origin lets files without $ORIGIN use relative names. let mut zone = Zonefile::from(format!("$ORIGIN {origin}.\n{text}").as_str()); let mut records = Vec::new(); loop { match zone.next_entry() { Ok(Some(Entry::Record(rec))) => { let rtype = match rec.data() { ZoneRecordData::A(_) => "A", ZoneRecordData::Aaaa(_) => "AAAA", ZoneRecordData::Caa(_) => "CAA", ZoneRecordData::Cname(_) => "CNAME", ZoneRecordData::Mx(_) => "MX", ZoneRecordData::Ns(_) => "NS", ZoneRecordData::Ptr(_) => "PTR", ZoneRecordData::Srv(_) => "SRV", ZoneRecordData::Txt(_) => "TXT", // The provider owns the SOA; nothing for us to sync. ZoneRecordData::Soa(_) => continue, _ => { bail!( "{}: {} records are not supported", rec.owner(), rec.rtype() ); } }; records.push( Record::parse( &rec.owner().to_string(), rtype, rec.ttl().as_secs(), &rec.data().to_string(), ) .with_context(|| rec.owner().to_string())?, ); } Ok(Some(Entry::Include { .. })) => { bail!("$INCLUDE is not supported; keep each zone in a single file"); } Ok(None) => return Ok(records), Err(e) => bail!("zone file error: {}", e), } } } /// Render records as an RFC 1035 master file for `zone`. /// `foreign` records (types we don't manage) are included as comments so /// they're visible without breaking a later parse. pub fn render(zone: &Zone, foreign: &[ForeignRecord]) -> String { let apex = &zone.name; let suffix = format!(".{apex}"); let relative = |name: &str| -> String { if name == apex.as_str() { "@".into() } else { name.strip_suffix(&suffix).unwrap_or(name).to_string() } }; let mut sorted: Vec<&Record> = zone.records.iter().collect(); sorted.sort_by(|a, b| { name_key(a.name.as_str()) .cmp(&name_key(b.name.as_str())) .then_with(|| a.rtype().cmp(b.rtype())) }); let width = sorted .iter() .map(|r| relative(r.name.as_str()).len()) .max() .unwrap_or(1); let mut out = format!("$ORIGIN {apex}\n$TTL 3600\n\n"); for r in &sorted { out += &format!( "{:5} IN {:<5} {}\n", relative(r.name.as_str()), r.ttl, r.rtype(), render_rdata(&r.data) ); } if !foreign.is_empty() { let mut foreign_sorted: Vec<(Name, &ForeignRecord)> = foreign.iter().map(|f| (Name::new(&f.name), f)).collect(); foreign_sorted.sort_by(|a, b| name_key(a.0.as_str()).cmp(&name_key(b.0.as_str()))); out += "\n; present at the provider, but of an unmanaged type:\n"; for (name, r) in foreign_sorted { out += &format!( "; {:5} IN {:<5} {}\n", relative(name.as_str()), r.ttl, r.rtype, r.content ); } } out } fn render_rdata(data: &RData) -> String { match data { RData::Txt(s) => quote_txt(s), RData::Caa { flags, tag, value } => format!("{flags} {tag} \"{value}\""), _ => data.to_string(), } } /// Canonical DNS name order: compare labels from the rightmost, so records /// cluster by subdomain (`_mytxt.a` sorts before `_aaa.b`, and the apex — /// being a suffix of every other name — naturally comes first). fn name_key(name: &str) -> Vec<&str> { name.trim_end_matches('.').split('.').rev().collect() } /// A character-string is at most 255 bytes, so long values (2048-bit DKIM /// keys are the usual culprit) must be split into quoted chunks. fn quote_txt(s: &str) -> String { let mut chunks = Vec::new(); let mut rest = s; while rest.len() > 255 { let mut cut = 255; while !rest.is_char_boundary(cut) { cut -= 1; } // Don't orphan the backslash of an escape sequence. if rest.as_bytes()[cut - 1] == b'\\' { cut -= 1; } chunks.push(&rest[..cut]); rest = &rest[cut..]; } chunks.push(rest); chunks .iter() .map(|c| format!("\"{c}\"")) .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::*; const ZONE: &str = "\ $TTL 3600 @ IN SOA ns1.cloudflare.com. dns.example.com. ( 2026072201 ; serial 7200 3600 86400 3600 ) IN NS ns1.cloudflare.com. IN A 203.0.113.10 IN MX 10 mail www IN CNAME @ api 60 IN A 203.0.113.20 @ IN TXT \"v=spf1 mx -all\" _sip._tcp IN SRV 0 5 5060 sip.example.com. @ IN CAA 0 issue \"letsencrypt.org\" "; #[test] fn parses_a_realistic_zone() { let records = parse(ZONE, "example.com").unwrap(); let has = |name: &str, rtype: &str, rdata: &str| { records.iter().any(|r| { r.name.as_str() == name && r.rtype() == rtype && r.data.to_string() == rdata }) }; assert!(has("www.example.com.", "CNAME", "example.com.")); assert!(has("example.com.", "MX", "10 mail.example.com.")); assert!(has("example.com.", "TXT", "v=spf1 mx -all")); assert!(has( "_sip._tcp.example.com.", "SRV", "0 5 5060 sip.example.com." )); assert!(has("example.com.", "CAA", "0 issue letsencrypt.org")); assert!(!records.iter().any(|r| r.rtype() == "SOA")); assert!(records.iter().all(|r| r.ttl == 3600 || r.ttl == 60)); let api = records .iter() .find(|r| r.name.as_str() == "api.example.com.") .unwrap(); assert_eq!(api.ttl, 60); } #[test] fn render_then_parse_is_the_identity() { let zone = Zone { name: Name::new("example.com"), records: parse(ZONE, "example.com").unwrap(), }; let rendered = render(&zone, &[]); let mut reparsed = parse(&rendered, "example.com").unwrap(); let by_key = |a: &Record, b: &Record| a.key().cmp(&b.key()); let mut records = zone.records.clone(); records.sort_by(by_key); reparsed.sort_by(by_key); assert_eq!(records, reparsed); } #[test] fn render_sorts_by_reversed_labels() { let records = vec![ Record::parse("www.example.com.", "A", 3600, "203.0.113.2").unwrap(), Record::parse("_aaa.b.example.com.", "TXT", 3600, "1").unwrap(), Record::parse("_mytxt.a.example.com.", "TXT", 3600, "2").unwrap(), Record::parse("b.example.com.", "A", 3600, "203.0.113.3").unwrap(), Record::parse("example.com.", "A", 3600, "203.0.113.1").unwrap(), ]; let zone = Zone { name: Name::new("example.com"), records, }; let rendered = render(&zone, &[]); let names: Vec<&str> = rendered .lines() .filter(|l| !l.starts_with('$') && !l.is_empty()) .map(|l| l.split_whitespace().next().unwrap()) .collect(); assert_eq!(names, ["@", "_mytxt.a", "b", "_aaa.b", "www"]); } #[test] fn long_txt_is_chunked_and_round_trips() { let long = "x".repeat(600); let records = vec![Record::parse("example.com.", "TXT", 300, &long).unwrap()]; let zone = Zone { name: Name::new("example.com"), records: records.clone(), }; let rendered = render(&zone, &[]); let reparsed = parse(&rendered, "example.com").unwrap(); assert_eq!(records, reparsed); } }