diff --git a/Cargo.lock b/Cargo.lock index 2a3639b3..23e77b6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,12 +143,19 @@ dependencies = [ "serde", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "dns-test" version = "0.1.0" dependencies = [ "ctrlc", "minijinja", + "pretty_assertions", "serde", "serde_json", "serde_with", @@ -358,6 +365,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.78" @@ -701,3 +718,9 @@ name = "windows_x86_64_msvc" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" diff --git a/packages/dns-test/Cargo.toml b/packages/dns-test/Cargo.toml index 7dc0924a..eacbbf9c 100644 --- a/packages/dns-test/Cargo.toml +++ b/packages/dns-test/Cargo.toml @@ -18,3 +18,4 @@ doctest = false [dev-dependencies] ctrlc = "3.4.2" +pretty_assertions = "1.4.0" diff --git a/packages/dns-test/src/record.rs b/packages/dns-test/src/record.rs index c16a49f8..a6e1aef3 100644 --- a/packages/dns-test/src/record.rs +++ b/packages/dns-test/src/record.rs @@ -3,72 +3,77 @@ use core::result::Result as CoreResult; use core::str::FromStr; use core::{array, fmt}; +use std::any; use std::fmt::Write; use std::net::Ipv4Addr; use crate::{Error, Result, DEFAULT_TTL, FQDN}; -#[allow(clippy::upper_case_acronyms)] -#[derive(Debug, PartialEq)] -pub enum RecordType { - A, - DS, - NS, - SOA, - // excluded because cannot appear in RRSIG.type_covered - // RRSIG, -} +const CLASS: &str = "IN"; // "internet" -impl RecordType { - pub fn as_str(&self) -> &'static str { - match self { - RecordType::A => "A", - RecordType::DS => "DS", - RecordType::SOA => "SOA", - RecordType::NS => "NS", +macro_rules! record_types { + ($($variant:ident),*) => { + #[allow(clippy::upper_case_acronyms)] + #[derive(Debug, PartialEq)] + pub enum RecordType { + $($variant),* } - } + + impl RecordType { + pub fn as_str(&self) -> &'static str { + match self { + $(Self::$variant => stringify!($variant)),* + } + } + } + + impl FromStr for RecordType { + type Err = Error; + + fn from_str(input: &str) -> Result { + $(if input == stringify!($variant) { + return Ok(Self::$variant); + })* + + Err(format!("unknown record type: {input}").into()) + } + } + + 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 = Error; - - fn from_str(input: &str) -> CoreResult { - let record_type = match input { - "A" => Self::A, - "DS" => Self::DS, - "SOA" => Self::SOA, - "NS" => Self::NS, - _ => return Err(format!("unknown record type: {input}").into()), - }; - - Ok(record_type) - } -} - -impl fmt::Display for RecordType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - RecordType::A => "A", - RecordType::DS => "DS", - RecordType::NS => "NS", - RecordType::SOA => "SOA", - }; - - f.write_str(s) - } -} +record_types!(A, AAAA, DNSKEY, DS, MX, NS, NSEC3, NSEC3PARAM, RRSIG, SOA, TXT); #[derive(Debug)] #[allow(clippy::upper_case_acronyms)] pub enum Record { A(A), + DNSKEY(DNSKEY), DS(DS), NS(NS), + NSEC3(NSEC3), + NSEC3PARAM(NSEC3PARAM), RRSIG(RRSIG), SOA(SOA), } +impl From for Record { + fn from(v: NSEC3) -> Self { + Self::NSEC3(v) + } +} + +impl From for Record { + fn from(v: DNSKEY) -> Self { + Self::DNSKEY(v) + } +} + impl From for Record { fn from(v: DS) -> Self { Self::DS(v) @@ -150,7 +155,11 @@ impl FromStr for Record { let record = match record_type { "A" => Record::A(input.parse()?), - "NS" => todo!(), + "DNSKEY" => Record::DNSKEY(input.parse()?), + "DS" => Record::DS(input.parse()?), + "NS" => Record::NS(input.parse()?), + "NSEC3" => Record::NSEC3(input.parse()?), + "NSEC3PARAM" => Record::NSEC3PARAM(input.parse()?), "RRSIG" => Record::RRSIG(input.parse()?), "SOA" => Record::SOA(input.parse()?), _ => return Err(format!("unknown record type: {record_type}").into()), @@ -165,7 +174,10 @@ impl fmt::Display for Record { match self { Record::A(a) => write!(f, "{a}"), Record::DS(ds) => write!(f, "{ds}"), + Record::DNSKEY(dnskey) => write!(f, "{dnskey}"), Record::NS(ns) => write!(f, "{ns}"), + Record::NSEC3(nsec3) => write!(f, "{nsec3}"), + Record::NSEC3PARAM(nsec3param) => write!(f, "{nsec3param}"), Record::RRSIG(rrsig) => write!(f, "{rrsig}"), Record::SOA(soa) => write!(f, "{soa}"), } @@ -191,16 +203,8 @@ impl FromStr for A { return Err("expected 5 columns".into()); }; - let expected = "A"; - if record_type != expected { - return Err( - format!("tried to parse `{record_type}` record as an {expected} record").into(), - ); - } - - if class != "IN" { - return Err(format!("unknown class: {class}").into()); - } + check_record_type::(record_type)?; + check_class(class)?; Ok(Self { fqdn: fqdn.parse()?, @@ -218,7 +222,8 @@ impl fmt::Display for A { ipv4_addr, } = self; - write!(f, "{fqdn}\t{ttl}\tIN\tA\t{ipv4_addr}") + let record_type = unqualified_type_name::(); + write!(f, "{fqdn}\t{ttl}\t{CLASS}\t{record_type}\t{ipv4_addr}") } } @@ -252,7 +257,11 @@ impl DNSKEY { impl FromStr for DNSKEY { type Err = Error; - fn from_str(input: &str) -> CoreResult { + fn from_str(mut input: &str) -> Result { + if let Some((rr, _comment)) = input.rsplit_once(" ;") { + input = rr.trim_end(); + } + let mut columns = input.split_whitespace(); let [Some(zone), Some(ttl), Some(class), Some(record_type), Some(flags), Some(protocol), Some(algorithm)] = @@ -261,13 +270,8 @@ impl FromStr for DNSKEY { return Err("expected at least 7 columns".into()); }; - if record_type != "DNSKEY" { - return Err(format!("tried to parse `{record_type}` record as a DNSKEY record").into()); - } - - if class != "IN" { - return Err(format!("unknown class: {class}").into()); - } + check_record_type::(record_type)?; + check_class(class)?; let mut public_key = String::new(); for column in columns { @@ -296,9 +300,10 @@ impl fmt::Display for DNSKEY { public_key, } = self; + let record_type = unqualified_type_name::(); write!( f, - "{zone}\t{ttl}\tIN\tDNSKEY\t{flags} {protocol} {algorithm}" + "{zone}\t{ttl}\t{CLASS}\t{record_type}\t{flags} {protocol} {algorithm}" )?; write_split_long_string(f, public_key) @@ -318,7 +323,7 @@ pub struct DS { impl FromStr for DS { type Err = Error; - fn from_str(input: &str) -> CoreResult { + fn from_str(input: &str) -> Result { let mut columns = input.split_whitespace(); let [Some(zone), Some(ttl), Some(class), Some(record_type), Some(key_tag), Some(algorithm), Some(digest_type)] = @@ -327,16 +332,8 @@ impl FromStr for DS { return Err("expected at least 7 columns".into()); }; - let expected = "DS"; - if record_type != expected { - return Err( - format!("tried to parse `{record_type}` entry as a {expected} entry").into(), - ); - } - - if class != "IN" { - return Err(format!("unknown class: {class}").into()); - } + check_record_type::(record_type)?; + check_class(class)?; let mut digest = String::new(); for column in columns { @@ -365,9 +362,10 @@ impl fmt::Display for DS { digest, } = self; + let record_type = unqualified_type_name::(); write!( f, - "{zone}\t{ttl}\tIN\tDS\t{key_tag} {algorithm} {digest_type}" + "{zone}\t{ttl}\t{CLASS}\t{record_type}\t{key_tag} {algorithm} {digest_type}" )?; write_split_long_string(f, digest) @@ -389,7 +387,158 @@ impl fmt::Display for NS { nameserver, } = self; - write!(f, "{zone}\t{ttl}\tIN\tNS {nameserver}") + let record_type = unqualified_type_name::(); + write!(f, "{zone}\t{ttl}\t{CLASS}\t{record_type}\t{nameserver}") + } +} + +impl FromStr for NS { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut columns = input.split_whitespace(); + + let [Some(zone), Some(ttl), Some(class), Some(record_type), Some(nameserver), None] = + array::from_fn(|_| columns.next()) + else { + return Err("expected 5 columns".into()); + }; + + check_record_type::(record_type)?; + check_class(class)?; + + Ok(Self { + zone: zone.parse()?, + ttl: ttl.parse()?, + nameserver: nameserver.parse()?, + }) + } +} + +// integer types chosen based on bit sizes in section 3.2 of RFC5155 +#[derive(Debug)] +pub struct NSEC3 { + pub fqdn: FQDN, + pub ttl: u32, + pub hash_alg: u8, + pub flags: u8, + pub iterations: u16, + pub salt: String, + pub next_hashed_owner_name: String, + pub record_types: Vec, +} + +impl FromStr for NSEC3 { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut columns = input.split_whitespace(); + + let [Some(fqdn), Some(ttl), Some(class), Some(record_type), Some(hash_alg), Some(flags), Some(iterations), Some(salt), Some(next_hashed_owner_name)] = + array::from_fn(|_| columns.next()) + else { + return Err("expected at least 9 columns".into()); + }; + + check_record_type::(record_type)?; + check_class(class)?; + + let mut record_types = vec![]; + for column in columns { + record_types.push(column.parse()?); + } + + Ok(Self { + fqdn: fqdn.parse()?, + ttl: ttl.parse()?, + hash_alg: hash_alg.parse()?, + flags: flags.parse()?, + iterations: iterations.parse()?, + salt: salt.to_string(), + next_hashed_owner_name: next_hashed_owner_name.to_string(), + record_types, + }) + } +} + +impl fmt::Display for NSEC3 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + fqdn, + ttl, + hash_alg, + flags, + iterations, + salt, + next_hashed_owner_name, + record_types, + } = self; + + let record_type = unqualified_type_name::(); + write!(f, "{fqdn}\t{ttl}\t{CLASS}\t{record_type}\t{hash_alg} {flags} {iterations} {salt} {next_hashed_owner_name}")?; + + for record_type in record_types { + write!(f, " {record_type}")?; + } + + Ok(()) + } +} + +// integer types chosen based on bit sizes in section 4.2 of RFC5155 +#[derive(Debug)] +pub struct NSEC3PARAM { + pub zone: FQDN, + pub ttl: u32, + pub hash_alg: u8, + pub flags: u8, + pub iterations: u16, +} + +impl FromStr for NSEC3PARAM { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut columns = input.split_whitespace(); + + let [Some(zone), Some(ttl), Some(class), Some(record_type), Some(hash_alg), Some(flags), Some(iterations), Some(dash), None] = + array::from_fn(|_| columns.next()) + else { + return Err("expected 8 columns".into()); + }; + + check_record_type::(record_type)?; + check_class(class)?; + + if dash != "-" { + todo!("salt is not implemented") + } + + Ok(Self { + zone: zone.parse()?, + ttl: ttl.parse()?, + hash_alg: hash_alg.parse()?, + flags: flags.parse()?, + iterations: iterations.parse()?, + }) + } +} + +impl fmt::Display for NSEC3PARAM { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + zone, + ttl, + hash_alg, + flags, + iterations, + } = self; + + let record_type = unqualified_type_name::(); + write!( + f, + "{zone}\t{ttl}\t{CLASS}\t{record_type}\t{hash_alg} {flags} {iterations} -" + ) } } @@ -422,16 +571,8 @@ impl FromStr for RRSIG { return Err("expected at least 12 columns".into()); }; - let expected = "RRSIG"; - if record_type != expected { - return Err( - format!("tried to parse `{record_type}` record as a {expected} record").into(), - ); - } - - if class != "IN" { - return Err(format!("unknown class: {class}").into()); - } + check_record_type::(record_type)?; + check_class(class)?; let mut signature = String::new(); for column in columns { @@ -470,7 +611,8 @@ impl fmt::Display for RRSIG { signature, } = self; - write!(f, "{fqdn}\t{ttl}\tIN\tRRSIG\t{type_covered} {algorithm} {labels} {original_ttl} {signature_expiration} {signature_inception} {key_tag} {signer_name}")?; + let record_type = unqualified_type_name::(); + write!(f, "{fqdn}\t{ttl}\t{CLASS}\t{record_type}\t{type_covered} {algorithm} {labels} {original_ttl} {signature_expiration} {signature_inception} {key_tag} {signer_name}")?; write_split_long_string(f, signature) } @@ -498,13 +640,8 @@ impl FromStr for SOA { return Err("expected 11 columns".into()); }; - if record_type != "SOA" { - return Err(format!("tried to parse `{record_type}` record as a SOA record").into()); - } - - if class != "IN" { - return Err(format!("unknown class: {class}").into()); - } + check_record_type::(record_type)?; + check_class(class)?; Ok(Self { zone: zone.parse()?, @@ -532,7 +669,11 @@ impl fmt::Display for SOA { settings, } = self; - write!(f, "{zone}\t{ttl}\tIN\tSOA\t{nameserver} {admin} {settings}") + let record_type = unqualified_type_name::(); + write!( + f, + "{zone}\t{ttl}\t{CLASS}\t{record_type}\t{nameserver} {admin} {settings}" + ) } } @@ -571,6 +712,32 @@ impl fmt::Display for SoaSettings { } } +fn check_class(class: &str) -> Result<()> { + if class != "IN" { + return Err(format!("unknown class: {class}").into()); + } + + Ok(()) +} + +fn check_record_type(record_type: &str) -> Result<()> { + let expected = unqualified_type_name::(); + if record_type == expected { + Ok(()) + } else { + Err(format!("tried to parse `{record_type}` record as an {expected} record").into()) + } +} + +fn unqualified_type_name() -> &'static str { + let name = any::type_name::(); + if let Some((_rest, component)) = name.rsplit_once(':') { + component + } else { + name + } +} + fn write_split_long_string(f: &mut fmt::Formatter<'_>, field: &str) -> fmt::Result { for (index, c) in field.chars().enumerate() { if index % 56 == 0 { @@ -585,31 +752,34 @@ fn write_split_long_string(f: &mut fmt::Formatter<'_>, field: &str) -> fmt::Resu mod tests { use super::*; + use pretty_assertions::assert_eq; + + // dig A a.root-servers.net + const A_INPUT: &str = "a.root-servers.net. 77859 IN A 198.41.0.4"; + #[test] fn a() -> Result<()> { - // dig A a.root-servers.net - let input = "a.root-servers.net. 77859 IN A 198.41.0.4"; let a @ A { fqdn, ttl, ipv4_addr, - } = &input.parse()?; + } = &A_INPUT.parse()?; assert_eq!("a.root-servers.net.", fqdn.as_str()); assert_eq!(77859, *ttl); assert_eq!(Ipv4Addr::new(198, 41, 0, 4), *ipv4_addr); let output = a.to_string(); - assert_eq!(output, input); + assert_eq!(A_INPUT, output); Ok(()) } + // dig DNSKEY . + const DNSKEY_INPUT: &str = ". 1116 IN DNSKEY 257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3 +/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kv ArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF 0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+e oZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfd RUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwN R1AkUTV74bU="; + #[test] fn dnskey() -> Result<()> { - // dig DNSKEY . - let input = ". 1116 IN DNSKEY 257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3 +/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kv ArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF 0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+e oZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfd RUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwN R1AkUTV74bU="; - let dnskey @ DNSKEY { zone, ttl, @@ -617,7 +787,7 @@ mod tests { protocol, algorithm, public_key, - } = &input.parse()?; + } = &DNSKEY_INPUT.parse()?; assert_eq!(FQDN::ROOT, *zone); assert_eq!(1116, *ttl); @@ -628,16 +798,30 @@ mod tests { assert_eq!(expected, public_key); let output = dnskey.to_string(); - assert_eq!(output, input); + assert_eq!(DNSKEY_INPUT, output); Ok(()) } #[test] - fn ds() -> Result<()> { - // dig DS com. - let input = "com. 7612 IN DS 19718 13 2 8ACBB0CD28F41250A80A491389424D341522D946B0DA0C0291F2D3D7 71D7805A"; + fn parsing_dnskey_ignores_trailing_comment() -> Result<()> { + // `ldns-signzone`'s output + const DNSKEY_INPUT2: &str = ". 86400 IN DNSKEY 256 3 7 AwEAAbEzD/uB2WK89f+PJ1Lyg5xvdt9mXge/R5tiQl8SEAUh/kfbn8jQiakH3HbBnBtdNXpjYrsmM7AxMmJLrp75dFMVnl5693/cY5k4dSk0BFJPQtBsZDn/7Q1rviQn0gqKNjaUfISuRpgCIWFKdRtTdq1VRDf3qIn7S/nuhfWE4w15 ;{id = 11387 (zsk), size = 1024b}"; + let DNSKEY { public_key, .. } = DNSKEY_INPUT2.parse()?; + + let expected = "AwEAAbEzD/uB2WK89f+PJ1Lyg5xvdt9mXge/R5tiQl8SEAUh/kfbn8jQiakH3HbBnBtdNXpjYrsmM7AxMmJLrp75dFMVnl5693/cY5k4dSk0BFJPQtBsZDn/7Q1rviQn0gqKNjaUfISuRpgCIWFKdRtTdq1VRDf3qIn7S/nuhfWE4w15"; + assert_eq!(expected, public_key); + + Ok(()) + } + + // dig DS com. + const DS_INPUT: &str = + "com. 7612 IN DS 19718 13 2 8ACBB0CD28F41250A80A491389424D341522D946B0DA0C0291F2D3D7 71D7805A"; + + #[test] + fn ds() -> Result<()> { let ds @ DS { zone, ttl, @@ -645,7 +829,7 @@ mod tests { algorithm, digest_type, digest, - } = &input.parse()?; + } = &DS_INPUT.parse()?; assert_eq!(FQDN::COM, *zone); assert_eq!(7612, *ttl); @@ -656,16 +840,109 @@ mod tests { assert_eq!(expected, digest); let output = ds.to_string(); - assert_eq!(output, input); + assert_eq!(DS_INPUT, output); Ok(()) } + // dig NS . + const NS_INPUT: &str = ". 86400 IN NS f.root-servers.net."; + + #[test] + fn ns() -> Result<()> { + let ns @ NS { + zone, + ttl, + nameserver, + } = &NS_INPUT.parse()?; + + assert_eq!(FQDN::ROOT, *zone); + assert_eq!(86400, *ttl); + assert_eq!("f.root-servers.net.", nameserver.as_str()); + + let output = ns.to_string(); + assert_eq!(NS_INPUT, output); + + Ok(()) + } + + // dig +dnssec A unicorn.example.com. + const NSEC3_INPUT: &str = "abhif1b25fhcda5amfk5hnrsh6jid2ki.example.com. 3571 IN NSEC3 1 0 5 53BCBC5805D2B761 GVPMD82B8ER38VUEGP72I721LIH19RGR A NS SOA MX TXT AAAA RRSIG DNSKEY NSEC3PARAM"; + + #[test] + fn nsec3() -> Result<()> { + let nsec3 @ NSEC3 { + fqdn, + ttl, + hash_alg, + flags, + iterations, + salt, + next_hashed_owner_name, + record_types, + } = &NSEC3_INPUT.parse()?; + + assert_eq!( + "abhif1b25fhcda5amfk5hnrsh6jid2ki.example.com.", + fqdn.as_str() + ); + assert_eq!(3571, *ttl); + assert_eq!(1, *hash_alg); + assert_eq!(0, *flags); + assert_eq!(5, *iterations); + assert_eq!("53BCBC5805D2B761", salt); + assert_eq!("GVPMD82B8ER38VUEGP72I721LIH19RGR", next_hashed_owner_name); + assert_eq!( + [ + RecordType::A, + RecordType::NS, + RecordType::SOA, + RecordType::MX, + RecordType::TXT, + RecordType::AAAA, + RecordType::RRSIG, + RecordType::DNSKEY, + RecordType::NSEC3PARAM + ], + record_types.as_slice() + ); + + let output = nsec3.to_string(); + assert_eq!(NSEC3_INPUT, output); + + Ok(()) + } + + // dig NSEC3PARAM com. + const NSEC3PARAM_INPUT: &str = "com. 86238 IN NSEC3PARAM 1 0 0 -"; + + #[test] + fn nsec3param() -> Result<()> { + let nsec3param @ NSEC3PARAM { + zone, + ttl, + hash_alg, + flags, + iterations, + } = &NSEC3PARAM_INPUT.parse()?; + + assert_eq!(FQDN::COM, *zone); + assert_eq!(86238, *ttl); + assert_eq!(1, *hash_alg); + assert_eq!(0, *flags); + assert_eq!(0, *iterations); + + let output = nsec3param.to_string(); + assert_eq!(NSEC3PARAM_INPUT, output); + + Ok(()) + } + + // dig +dnssec SOA . + const RRSIG_INPUT: &str = ". 1800 IN RRSIG SOA 7 0 1800 20240306132701 20240207132701 11264 . wXpRU4elJPGYm2kgVVsIwGf1IkYJcQ3UE4mwmItWdxj0XWSWY07MO4Ll DMJgsE0u64Q/345Ck7+aQ904uLebwCvpFnsmkyCxk82XIAfHN9FiwzSy qoR/zZEvBONaej3vrvsqPwh8q/pvypLft9647HcFdwY0juzZsbrAaDAX 8WY="; + #[test] fn rrsig() -> Result<()> { - // dig +dnssec SOA . - let input = ". 1800 IN RRSIG SOA 7 0 1800 20240306132701 20240207132701 11264 . wXpRU4elJPGYm2kgVVsIwGf1IkYJcQ3UE4mwmItWdxj0XWSWY07MO4Ll DMJgsE0u64Q/345Ck7+aQ904uLebwCvpFnsmkyCxk82XIAfHN9FiwzSy qoR/zZEvBONaej3vrvsqPwh8q/pvypLft9647HcFdwY0juzZsbrAaDAX 8WY="; - let rrsig @ RRSIG { fqdn, ttl, @@ -678,7 +955,7 @@ mod tests { key_tag, signer_name, signature, - } = &input.parse()?; + } = &RRSIG_INPUT.parse()?; assert_eq!(FQDN::ROOT, *fqdn); assert_eq!(1800, *ttl); @@ -694,17 +971,18 @@ mod tests { assert_eq!(expected, signature); let output = rrsig.to_string(); - assert_eq!(input, output); + assert_eq!(RRSIG_INPUT, output); Ok(()) } + // dig SOA . + const SOA_INPUT: &str = + ". 15633 IN SOA a.root-servers.net. nstld.verisign-grs.com. 2024020501 1800 900 604800 86400"; + #[test] fn soa() -> Result<()> { - // dig SOA . - let input = ". 15633 IN SOA a.root-servers.net. nstld.verisign-grs.com. 2024020501 1800 900 604800 86400"; - - let soa: SOA = input.parse()?; + let soa: SOA = SOA_INPUT.parse()?; assert_eq!(".", soa.zone.as_str()); assert_eq!(15633, soa.ttl); @@ -718,7 +996,21 @@ mod tests { assert_eq!(86400, settings.minimum); let output = soa.to_string(); - assert_eq!(output, input); + assert_eq!(SOA_INPUT, output); + + Ok(()) + } + + #[test] + fn any() -> Result<()> { + assert!(matches!(A_INPUT.parse()?, Record::A(..))); + assert!(matches!(DNSKEY_INPUT.parse()?, Record::DNSKEY(..))); + assert!(matches!(DS_INPUT.parse()?, Record::DS(..))); + assert!(matches!(NS_INPUT.parse()?, Record::NS(..))); + assert!(matches!(NSEC3_INPUT.parse()?, Record::NSEC3(..))); + assert!(matches!(NSEC3PARAM_INPUT.parse()?, Record::NSEC3PARAM(..))); + assert!(matches!(RRSIG_INPUT.parse()?, Record::RRSIG(..))); + assert!(matches!(SOA_INPUT.parse()?, Record::SOA(..))); Ok(()) } diff --git a/packages/dns-test/src/zone_file.rs b/packages/dns-test/src/zone_file/mod.rs similarity index 79% rename from packages/dns-test/src/zone_file.rs rename to packages/dns-test/src/zone_file/mod.rs index e4bb46c5..57a6c29a 100644 --- a/packages/dns-test/src/zone_file.rs +++ b/packages/dns-test/src/zone_file/mod.rs @@ -57,6 +57,40 @@ impl fmt::Display for ZoneFile { } } +impl FromStr for ZoneFile { + type Err = Error; + + fn from_str(input: &str) -> Result { + let mut records = vec![]; + let mut maybe_soa = None; + for line in input.lines() { + let line = line.trim(); + + if line.is_empty() { + continue; + } + + let record: Record = line.parse()?; + if let Record::SOA(soa) = record { + if maybe_soa.is_some() { + return Err("found more than one SOA record".into()); + } + + maybe_soa = Some(soa); + } else { + records.push(record) + } + } + + let soa = maybe_soa.ok_or("no SOA record found in zone file")?; + Ok(Self { + origin: soa.zone.clone(), + soa, + records, + }) + } +} + /// A root (server) hint pub struct Root { pub ipv4_addr: Ipv4Addr, @@ -154,6 +188,8 @@ impl FromStr for DNSKEY { mod tests { use super::*; + use pretty_assertions::assert_eq; + #[test] fn dnskey() -> Result<()> { let input = ". IN DNSKEY 256 3 7 AwEAAaCUpg+5lH7vart4WiMw4lbbkTNKfkvoyXWsAj09Cc5lT1bFo6sS7o4evhzXU9+iDGZkWZnnkwWg2thXfGgNdfQNTKW/Owz9UMDGv5yjkANKI3fI4jHn7Xp1qIZAwZG0W3RU26s7vkKWVcmA3mrKlDIX9r4BRIZrBVOtNgiHydbB ;{id = 42933 (zsk), size = 1024b}"; @@ -175,4 +211,15 @@ mod tests { Ok(()) } + + #[test] + fn roundtrip() -> Result<()> { + // `ldns-signzone`'s output minus trailing comments; long trailing fields have been split as well + let input = include_str!("muster.zone"); + let zone: ZoneFile = input.parse()?; + let output = zone.to_string(); + assert_eq!(input, output); + + Ok(()) + } } diff --git a/packages/dns-test/src/zone_file/muster.zone b/packages/dns-test/src/zone_file/muster.zone new file mode 100644 index 00000000..7fab9f0e --- /dev/null +++ b/packages/dns-test/src/zone_file/muster.zone @@ -0,0 +1,11 @@ +. 86400 IN SOA primary0.nameservers.com. admin0.nameservers.com. 2024022028 1800 900 604800 86400 +. 86400 IN RRSIG SOA 7 0 86400 20240319104519 20240220104519 11387 . Ks9b5tMyNxxrvw3JkgGkR2H5NPqTDwAwmwh3B7iNC0UHAYGU4B01ZJHj DIsJqDoJ2hsKG5oq0hQuwBSKv2nSBA1oSQcNrBDzOk105gu6tsXg2O8V ZCpAtEColco5ziOX8AWRqRMM5adSfA4xyj5H3NToMjRVDLpVpZsU4BAa 4dU= +. 86400 IN NS primary0.nameservers.com. +. 86400 IN RRSIG NS 7 0 86400 20240319104519 20240220104519 11387 . rZpACeVX3m2CwI/gY/rVYNOAs6ge4h+M74yV+CoAZYJaJLjeHd+jY0YV ixU3hap9bbFCZqhXKU5WSpJSsc/9PrgxEt2XycpbvAJwvIwdqWLUW741 /AOwnyrgv+7PLp4vkDdeLI9tcsY5V/ABpQrYW2i8Gtz90OEpvEEd5+4C LyU= +. 86400 IN DNSKEY 256 3 7 AwEAAbEzD/uB2WK89f+PJ1Lyg5xvdt9mXge/R5tiQl8SEAUh/kfbn8jQ iakH3HbBnBtdNXpjYrsmM7AxMmJLrp75dFMVnl5693/cY5k4dSk0BFJP QtBsZDn/7Q1rviQn0gqKNjaUfISuRpgCIWFKdRtTdq1VRDf3qIn7S/nu hfWE4w15 +. 86400 IN DNSKEY 257 3 7 AwEAAco2Ck4XM5M4RO+QiwZhMFW9Hf8s0cOWH6QZ8OUQisjP6n+gYsbE pOOHhRiABN+QuVhRK9BN+Mt0LqMSBjSy53t5P3NerckqUTQ4HlZkn2QK bhc+TOgvHN5iDj0RBMkTaJ09y5vYmeNv5npFk6hV+VsbBoFRLXTuPSms 8LsH72W6y1HEHNzvAd5H3ro1d2awp66CXRTOcbXbFAIELpTgAU6ZJjEo RBMASZ3Ug4oZ96yvegy2OZnAyFsxBGdOvecs+zoYKeezqaq21YMpnZkf eE7RYexGPm1p8/7smQjBph/uoVDp5k5DuPkTmzpafVOn2YHGB395vT37 uLi9B5Oef9c= +. 86400 IN RRSIG DNSKEY 7 0 86400 20240319104519 20240220104519 11245 . yH/aEcWQhgfmf8RjByMYDDuglaquWsHECA+nRmedIA4Kz7Vc74f77JLi QrhvFFSIFkQNyixNsTugLmTZunphbLrbQNKTWw8gpgd/8u6Oc9OdTYJu T+ADL+Rrgge7mDkPjDRKNhQ6VkIiRzwLBFhoYTA1LZF98CAnJGQcpw4W 1YCkqPbXIzsa3hq2OajC8NzZMEgeI95N1CJ/o5AmhwLtWVuv04q+seGX roSiTlWIQRKGsbCR2v97UjMG4l9XIbijzZbY4dK2/4WrCIw9mjp/cSE8 r/AdfegTi1oqOM9i4QebKvyU9c3rnJRVbFMhEXL1e0M/5bZNytXp43ex VTTHcA== +. 3600 IN NSEC3PARAM 1 0 1 - +. 3600 IN RRSIG NSEC3PARAM 7 0 3600 20240319104519 20240220104519 11387 . IhM+g5s6DwlFKbj6+zd+f/CqN1I4/QtF0aTOMvf0c+s5l+emx/yZEVCT 8LdX4cmz72eYeC4w/dM2btrhhHohhb/hdK1v7ukxtBVgvk6pOmuye2/E cuGkll7B59l+wlRaSmeXAQjiCUX6gyg9tlvmtcnomWVgtjIgMKJpggy8 B6k= +fasdp12mo9fh69ahu5bseugoh3np33tc. 86400 IN NSEC3 1 1 1 - fasdp12mo9fh69ahu5bseugoh3np33tc NS SOA RRSIG DNSKEY NSEC3PARAM +fasdp12mo9fh69ahu5bseugoh3np33tc. 86400 IN RRSIG NSEC3 7 1 86400 20240319104519 20240220104519 11387 . dsdwsTOGL5BvrC1v/5bmDy5Bz8wnN/IG3XRAg6RqKVMK0fLPMsd5uhXm U2gPJ5xUg1RkBQ5+etlBRm2p7vSDjbMa/hjRbvUJgP+c4dL68g+FcHv4 v9fb1Jaao9Goy/ZxZ1dbwXAdxhi+pyvikCdNcKsdiCtFD9pX7V5Nh2Cc GQQ=