From 4b25c746beadfbfe4aba1e84dd7e2d7fe48927e3 Mon Sep 17 00:00:00 2001 From: Weilence Date: Fri, 21 Nov 2025 19:51:55 +0800 Subject: [PATCH] Add support for CAA record type (#434) --- src/base/charstr.rs | 18 +- src/base/zonefile_fmt.rs | 15 + src/rdata/caa.rs | 701 +++++++++++++++++++++++++++++++++++++++ src/rdata/mod.rs | 6 + 4 files changed, 734 insertions(+), 6 deletions(-) create mode 100644 src/rdata/caa.rs diff --git a/src/base/charstr.rs b/src/base/charstr.rs index 06de2e68..d9e888e5 100644 --- a/src/base/charstr.rs +++ b/src/base/charstr.rs @@ -398,7 +398,7 @@ impl + ?Sized> CharStr { /// quotes. It will escape double quotes, backslashes, and non-printable /// octets only. pub fn display_quoted(&self) -> DisplayQuoted<'_> { - DisplayQuoted(self.for_slice()) + DisplayQuoted(self.0.as_ref()) } /// Returns an object that formats in unquoted presentation format. @@ -407,7 +407,7 @@ impl + ?Sized> CharStr { /// delimiters and escapes space, double quotes, semicolons, backslashes, /// and non-printable octets. pub fn display_unquoted(&self) -> DisplayUnquoted<'_> { - DisplayUnquoted(self.for_slice()) + DisplayUnquoted(self.0.as_ref()) } } @@ -927,12 +927,18 @@ impl Iterator for Iter<'_> { /// /// A value of this type can be obtained via `CharStr::display_quoted`. #[derive(Clone, Copy, Debug)] -pub struct DisplayQuoted<'a>(&'a CharStr<[u8]>); +pub struct DisplayQuoted<'a>(&'a [u8]); + +impl<'a> DisplayQuoted<'a> { + pub fn from_slice(octets: &'a [u8]) -> Self { + DisplayQuoted(octets) + } +} impl fmt::Display for DisplayQuoted<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("\"")?; - for &ch in self.0.as_ref() { + for &ch in self.0 { fmt::Display::fmt(&Symbol::quoted_from_octet(ch), f)?; } f.write_str("\"") @@ -945,11 +951,11 @@ impl fmt::Display for DisplayQuoted<'_> { /// /// A value of this type can be obtained via `CharStr::display_serialized`. #[derive(Clone, Copy, Debug)] -pub struct DisplayUnquoted<'a>(&'a CharStr<[u8]>); +pub struct DisplayUnquoted<'a>(&'a [u8]); impl fmt::Display for DisplayUnquoted<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for &ch in self.0.as_ref() { + for &ch in self.0 { fmt::Display::fmt(&Symbol::from_octet(ch), f)?; } Ok(()) diff --git a/src/base/zonefile_fmt.rs b/src/base/zonefile_fmt.rs index c5011eab..a137e639 100644 --- a/src/base/zonefile_fmt.rs +++ b/src/base/zonefile_fmt.rs @@ -322,6 +322,7 @@ mod test { use crate::base::iana::{Class, DigestAlgorithm, SecurityAlgorithm}; use crate::base::zonefile_fmt::{DisplayKind, ZonefileFmt}; use crate::base::{Name, Record, Ttl}; + use crate::rdata::caa::{CaaFlags, CaaTag}; use crate::rdata::{Cds, Cname, Ds, Mx, Txt, A}; fn create_record(data: Data) -> Record<&'static Name<[u8]>, Data> { @@ -495,4 +496,18 @@ mod test { record.display_zonefile(DisplayKind::Tabbed).to_string() ); } + + #[test] + fn caa_record() { + use crate::rdata::Caa; + let record = create_record(Caa::new( + CaaFlags::default(), + CaaTag::from_octets("issue".as_bytes()).unwrap(), + "ca.example.net".as_bytes(), + )); + assert_eq!( + "example.com. 3600 IN CAA 0 issue \"ca.example.net\"", + record.display_zonefile(DisplayKind::Simple).to_string() + ); + } } diff --git a/src/rdata/caa.rs b/src/rdata/caa.rs new file mode 100644 index 00000000..5925b056 --- /dev/null +++ b/src/rdata/caa.rs @@ -0,0 +1,701 @@ +//! Record data from [RFC 8659]: CAA records. +//! +//! This RFC defines the CAA record type. +//! +//! [RFC 8659]: https://www.rfc-editor.org/info/rfc8659 + +use crate::base::{ + charstr::DisplayQuoted, + name::FlattenInto, + rdata::ComposeRecordData, + scan::{Scan, Scanner, ScannerError}, + wire::{Compose, Parse, ParseError}, + zonefile_fmt::{self, Formatter, ZonefileFmt}, + CanonicalOrd, CharStr, ParseRecordData, RecordData, Rtype, +}; +use core::{cmp::Ordering, fmt, hash}; +#[cfg(feature = "serde")] +use octseq::{ + builder::{EmptyBuilder, FromBuilder}, + serde::DeserializeOctets, + serde::SerializeOctets, +}; +use octseq::{Octets, OctetsBuilder, OctetsFrom, OctetsInto, Parser}; + +//------------ Caa --------------------------------------------------------- + +/// Caa record data. +/// +/// The Certification Authority Authorization (CAA) DNS Resource Record allows +/// a DNS domain name holder to specify one or more Certification Authorities +/// (CAs) authorized to issue certificates for that domain name. +/// +/// CAA Resource Records allow a public CA to implement additional controls to reduce the +/// risk of unintended certificate mis-issue. +/// +/// The Caa record type is defined in [RFC 8659, section 4.1][1]. +/// +/// [1]: https://www.rfc-editor.org/rfc/rfc8659#section-4.1 +#[derive(Clone)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(bound( + serialize = " + Octs: SerializeOctets + AsRef<[u8]> + ", + deserialize = " + Octs: FromBuilder + DeserializeOctets<'de>, + ::Builder: + OctetsBuilder + EmptyBuilder + + AsRef<[u8]>, + ", + )) +)] +pub struct Caa { + flags: CaaFlags, + tag: CaaTag, + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "octseq::serde::SerializeOctets::serialize_octets", + deserialize_with = "octseq::serde::DeserializeOctets::deserialize_octets", + bound( + serialize = "Octs: octseq::serde::SerializeOctets", + deserialize = "Octs: octseq::serde::DeserializeOctets<'de>", + ) + ) + )] + value: Octs, +} + +impl Caa<()> { + /// The rtype of this record data type. + pub const RTYPE: Rtype = Rtype::CAA; +} + +impl Caa { + /// Creates a new CAA record data from the flags, tag, and value. + pub fn new(flags: CaaFlags, tag: CaaTag, value: Octs) -> Self { + Caa { flags, tag, value } + } + + /// Returns the flags. If the value is set to "1", the Property is critical. + /// A CA MUST NOT issue certificates for any FQDN if the + /// Relevant RRset for that FQDN contains a CAA critical + /// Property for an unknown or unsupported Property Tag. + pub fn flags(&self) -> CaaFlags { + self.flags + } + + /// Returns the Property identifier + pub fn tag(&self) -> &CaaTag { + &self.tag + } + + /// Returns the Property Value + pub fn value(&self) -> &Octs { + &self.value + } + + pub(in crate::rdata) fn convert_octets>( + self, + ) -> Result, TOcts::Error> { + Ok(Caa::new( + self.flags, + self.tag.try_octets_into()?, + self.value.try_octets_into()?, + )) + } + + pub(in crate::rdata) fn flatten>( + self, + ) -> Result, TOcts::Error> { + self.convert_octets() + } + + pub fn scan>( + scanner: &mut S, + ) -> Result + where + Octs: AsRef<[u8]>, + { + Ok(Self::new( + CaaFlags::scan(scanner)?, + CaaTag::scan(scanner)?, + scanner.scan_octets()?, + )) + } + + pub fn parse<'a, Src: Octets = Octs> + ?Sized>( + parser: &mut Parser<'a, Src>, + ) -> Result + where + Octs: AsRef<[u8]>, + { + Ok(Self::new( + CaaFlags::parse(parser)?, + CaaTag::parse(parser)?, + parser.parse_octets(parser.remaining())?, + )) + } +} + +//--- OctetsFrom + +impl OctetsFrom> for Caa +where + Octs: OctetsFrom, +{ + type Error = Octs::Error; + + fn try_octets_from(source: Caa) -> Result { + Ok(Caa { + flags: source.flags, + tag: CaaTag::try_octets_from(source.tag)?, + value: Octs::try_octets_from(source.value)?, + }) + } +} + +//--- FlattenInto + +impl FlattenInto> for Caa +where + TOcts: OctetsFrom, +{ + type AppendError = TOcts::Error; + + fn try_flatten_into(self) -> Result, Self::AppendError> { + self.flatten() + } +} + +//--- PartialEq and Eq + +impl PartialEq> for Caa +where + Octs: AsRef<[u8]>, + OtherOcts: AsRef<[u8]>, +{ + fn eq(&self, other: &Caa) -> bool { + self.flags == other.flags + && self.tag.eq(&other.tag) + && self.value.as_ref().eq(other.value.as_ref()) + } +} + +impl> Eq for Caa {} + +//--- PartialOrd, Ord, and CanonicalOrd + +impl PartialOrd> for Caa +where + Octs: AsRef<[u8]>, + OtherOcts: AsRef<[u8]>, +{ + fn partial_cmp(&self, other: &Caa) -> Option { + match self.flags.partial_cmp(&other.flags) { + Some(Ordering::Equal) => (), + other => return other, + } + match self.tag.partial_cmp(&other.tag) { + Some(Ordering::Equal) => (), + other => return other, + } + self.value.as_ref().partial_cmp(other.value.as_ref()) + } +} + +impl CanonicalOrd> for Caa +where + Octs: AsRef<[u8]>, + OtherOcts: AsRef<[u8]>, +{ + fn canonical_cmp(&self, other: &Caa) -> Ordering { + match self.flags.cmp(&other.flags) { + Ordering::Equal => (), + ord => return ord, + } + match self.tag.canonical_cmp(&other.tag) { + Ordering::Equal => (), + ord => return ord, + } + self.value.as_ref().cmp(other.value.as_ref()) + } +} + +impl> Ord for Caa { + fn cmp(&self, other: &Self) -> Ordering { + match self.flags.cmp(&other.flags) { + Ordering::Equal => (), + ord => return ord, + } + match self.tag.cmp(&other.tag) { + Ordering::Equal => (), + ord => return ord, + } + self.value.as_ref().cmp(other.value.as_ref()) + } +} + +//--- Hash + +impl> hash::Hash for Caa { + fn hash(&self, state: &mut H) { + self.flags.hash(state); + self.tag.hash(state); + self.value.as_ref().hash(state); + } +} + +//--- RecordData, ParseRecordData, ComposeRecordData + +impl RecordData for Caa { + fn rtype(&self) -> Rtype { + Caa::RTYPE + } +} + +impl<'a, Octs: Octets + ?Sized> ParseRecordData<'a, Octs> + for Caa> +{ + fn parse_rdata( + rtype: Rtype, + parser: &mut octseq::Parser<'a, Octs>, + ) -> Result, crate::base::wire::ParseError> { + if rtype == Caa::RTYPE { + Self::parse(parser).map(Some) + } else { + Ok(None) + } + } +} + +impl> ComposeRecordData for Caa { + fn rdlen(&self, _compress: bool) -> Option { + Some( + u8::COMPOSE_LEN + .checked_add(self.tag.compose_len()) + .expect("long tag") + .checked_add( + u16::try_from(self.value.as_ref().len()) + .expect("long value"), + ) + .expect("long value"), + ) + } + + fn compose_rdata( + &self, + target: &mut Target, + ) -> Result<(), Target::AppendError> { + self.flags.compose(target)?; + self.tag.compose(target)?; + target.append_slice(self.value.as_ref()) + } + + fn compose_canonical_rdata< + Target: crate::base::wire::Composer + ?Sized, + >( + &self, + target: &mut Target, + ) -> Result<(), Target::AppendError> { + self.flags.compose(target)?; + self.tag.compose(target)?; + target.append_slice(self.value.as_ref()) + } +} + +//--- Display + +impl> fmt::Display for Caa { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {} {}", + self.flags, + self.tag, + DisplayQuoted::from_slice(self.value.as_ref()), + ) + } +} + +//--- Debug + +impl> fmt::Debug for Caa { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Caa") + .field("flags", &self.flags) + .field("tag", &self.tag) + .field("value", &DisplayQuoted::from_slice(self.value.as_ref())) + .finish() + } +} + +//--- ZonefileFmt + +impl> ZonefileFmt for Caa { + fn fmt(&self, p: &mut impl Formatter) -> zonefile_fmt::Result { + p.block(|p| { + p.write_token(self.flags)?; + p.write_comment("flags")?; + p.write_token(&self.tag)?; + p.write_comment("tag")?; + p.write_token(DisplayQuoted::from_slice(self.value.as_ref()))?; + p.write_comment("value") + }) + } +} + +/// A CAA property tag as defined in [RFC 8659 section 4.1]. +/// +/// A CAA tag identifies the property name that an issuer must honor when +/// evaluating a certificate issuance request. RFC 8659 restricts the tag to +/// printable ASCII alphabetic characters and digits with a maximal length of +/// 255 octets, and the wire format is a length-prefixed string of those +/// characters. +/// +/// [RFC 8659 section 4.1]: https://www.rfc-editor.org/rfc/rfc8659#section-4.1 +#[derive(Clone)] +#[repr(transparent)] +pub struct CaaTag(CharStr); + +impl CaaTag { + /// Constructs a CAA tag from a `CharStr`, validating that it only contains + /// ASCII letters/digits and is at most 255 octets long. + pub fn new(charstr: CharStr) -> Result + where + Octs: AsRef<[u8]>, + { + CaaTag::check_slice(charstr.as_slice())?; + Ok(CaaTag(charstr)) + } + + /// Parses a CAA tag from an octets sequence while enforcing the same + /// validation as [`CaaTag::new`]. + pub fn from_octets(octets: Octs) -> Result + where + Octs: AsRef<[u8]>, + { + CaaTag::check_slice(octets.as_ref())?; + Ok(unsafe { Self::from_octets_unchecked(octets) }) + } + + /// Creates a CAA tag from octets without validation. + /// + /// # Safety + /// + /// The caller must ensure `octets` consists only of ASCII alphanumeric + /// characters and is no longer than 255 octets, as required by RFC 8659. + pub unsafe fn from_octets_unchecked(octets: Octs) -> Self { + CaaTag(CharStr::from_octets_unchecked(octets)) + } +} + +impl CaaTag<[u8]> { + /// Parses a CAA tag from a slice, validating it against the same rules as + /// [`CaaTag::new`]. + pub fn from_slice(slice: &[u8]) -> Result<&Self, ParseError> { + Self::check_slice(slice)?; + Ok(unsafe { Self::from_slice_unchecked(slice) }) + } + + /// Creates a new value from a slice without checking. + /// + /// # Safety + /// + /// The caller needs to make sure that the slice only contains ascii + /// alphanumeric characters and is not longer than 255 bytes. + pub unsafe fn from_slice_unchecked(slice: &[u8]) -> &Self { + // SAFETY: CaaTag has repr(transparent) + &*(CharStr::from_slice_unchecked(slice) as *const CharStr<[u8]> + as *const Self) + } + + fn check_slice(octets: &[u8]) -> Result<(), ParseError> { + if octets.iter().any(|e| !e.is_ascii_alphanumeric()) { + return Err(ParseError::form_error( + "CAA tag contains invalid character", + )); + } + Ok(()) + } +} + +impl> CaaTag { + /// Returns the length of the wire-format tag, which mirrors the length of + /// the underlying `CharStr`. + pub fn compose_len(&self) -> u16 { + self.0.compose_len() + } + + /// Writes the tag into `target` using the standard length-prefixed wire + /// format built from the ASCII characters of the tag. + pub fn compose( + &self, + target: &mut Target, + ) -> Result<(), Target::AppendError> { + self.0.compose(target) + } + + /// Scans a CAA tag from the scanner, enforcing the ASCII rules used by + /// the CAA property tag. + pub fn scan>( + scanner: &mut S, + ) -> Result { + let octets = CharStr::scan(scanner)?; + CaaTag::check_slice(octets.as_slice()).map_err(|_| { + S::Error::custom("CAA tag contains invalid character") + })?; + Ok(CaaTag(octets)) + } + + /// Parses a CAA tag from the parser while validating it for ASCII letters + /// and digits with a valid length. + pub fn parse<'a, Src: Octets = Octs> + ?Sized>( + parser: &mut Parser<'a, Src>, + ) -> Result { + Self::new(CharStr::parse(parser)?) + } +} + +impl OctetsFrom> for CaaTag +where + Octs: OctetsFrom, +{ + type Error = Octs::Error; + + fn try_octets_from(source: CaaTag) -> Result { + Ok(CaaTag(CharStr::try_octets_from(source.0)?)) + } +} + +//--- PartialEq and Eq + +impl PartialEq> for CaaTag +where + Octs: AsRef<[u8]>, + OtherOcts: AsRef<[u8]>, +{ + fn eq(&self, other: &CaaTag) -> bool { + self.0.eq(&other.0) + } +} + +impl> Eq for CaaTag {} + +//--- PartialOrd, Ord, and CanonicalOrd + +impl PartialOrd> for CaaTag +where + Octs: AsRef<[u8]>, + OtherOcts: AsRef<[u8]>, +{ + fn partial_cmp(&self, other: &CaaTag) -> Option { + self.0.partial_cmp(&other.0) + } +} +impl CanonicalOrd> for CaaTag +where + Octs: AsRef<[u8]>, + OtherOcts: AsRef<[u8]>, +{ + fn canonical_cmp(&self, other: &CaaTag) -> Ordering { + self.0.canonical_cmp(&other.0) + } +} + +impl> Ord for CaaTag { + fn cmp(&self, other: &Self) -> Ordering { + self.0.cmp(&other.0) + } +} + +//--- Hash +impl> hash::Hash for CaaTag { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +//--- Display and Debug +impl> fmt::Display for CaaTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl> fmt::Debug for CaaTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("CaaTag").field(&self.0).finish() + } +} + +//--- Serialize and Deserialize + +#[cfg(feature = "serde")] +impl serde::Serialize for CaaTag +where + Octs: AsRef<[u8]> + octseq::serde::SerializeOctets, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +#[cfg(feature = "serde")] +impl<'de, Octs> serde::Deserialize<'de> for CaaTag +where + Octs: FromBuilder + octseq::serde::DeserializeOctets<'de>, + ::Builder: AsRef<[u8]> + EmptyBuilder, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::new(CharStr::deserialize(deserializer)?) + .map_err(serde::de::Error::custom) + } +} + +/// CAA flags as defined in [RFC 8659 section 4.1]. +/// +/// The only defined flag is the critical flag (bit 7). +/// You can create a plain CAA flags instance with [CaaFlags::default()] +/// or critical CAA flags with [CaaFlags::critical()]. +/// +/// The [CaaFlags::new()] method allows creating a CAA flags instance +/// from any underlying byte, but be aware that only bit 7 is defined. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CaaFlags(u8); + +impl CaaFlags { + /// Creates a new CAA flags instance from the underlying byte. + pub fn new(bits: u8) -> Self { + CaaFlags(bits) + } + + /// Creates a CAA flags instance with the critical flag set. + pub fn critical() -> Self { + CaaFlags(0x80) + } + + /// Returns the underlying flags byte. + pub fn bits(&self) -> u8 { + self.0 + } +} + +impl fmt::Display for CaaFlags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Compose for CaaFlags { + fn compose( + &self, + target: &mut Target, + ) -> Result<(), Target::AppendError> { + self.0.compose(target) + } +} + +impl Scan for CaaFlags { + fn scan(scanner: &mut S) -> Result { + Ok(CaaFlags(u8::scan(scanner)?)) + } +} + +impl<'a, Octs: AsRef<[u8]> + ?Sized> Parse<'a, Octs> for CaaFlags { + fn parse(parser: &mut Parser<'a, Octs>) -> Result { + Ok(CaaFlags(u8::parse(parser)?)) + } +} + +#[cfg(test)] +#[cfg(all(feature = "std", feature = "bytes"))] +mod test { + use super::*; + use crate::std::string::ToString; + use octseq::array::Array; + + #[test] + fn caa_eq() { + let caa1 = Caa::new( + CaaFlags::default(), + CaaTag::from_octets("ISSUE".as_bytes()).unwrap(), + "ca.example.net".as_bytes(), + ); + let caa2 = Caa::new( + CaaFlags::default(), + CaaTag::from_octets("issue".as_bytes()).unwrap(), + "ca.example.net".as_bytes(), + ); + assert_eq!(caa1, caa2); + } + + #[test] + fn caa_octets_info() { + let caa = Caa::new( + CaaFlags::default(), + CaaTag::from_octets("issue".as_bytes()).unwrap(), + "ca.example.net".as_bytes(), + ); + let caa_bytes: Caa = caa.clone().octets_into(); + assert_eq!(caa.flags, caa_bytes.flags); + assert_eq!(caa.tag, caa_bytes.tag); + assert_eq!(caa.value, caa_bytes.value); + } + + #[test] + fn caa_display() { + let caa = Caa::new( + CaaFlags::default(), + CaaTag::from_octets("issue".as_bytes()).unwrap(), + "ca.example.net".as_bytes(), + ); + + assert_eq!(caa.to_string(), r#"0 issue "ca.example.net""#); + } + + #[test] + fn caa_tag_creation_and_validation() { + assert!(CaaTag::from_octets("issue".as_bytes()).is_ok()); + assert!(CaaTag::from_octets("bad tag".as_bytes()).is_err()); + } + + #[test] + fn caa_tag_display_and_debug() { + let tag = CaaTag::from_octets("ISSUE".as_bytes()).unwrap(); + assert_eq!(tag.to_string(), "ISSUE"); + assert_eq!(format!("{:?}", tag), "CaaTag(CharStr(ISSUE))"); + } + + #[test] + fn caa_tag_compose_canonical_lowercases() { + let tag = CaaTag::from_octets("Issue".as_bytes()).unwrap(); + let mut buf = Array::<8>::new(); + tag.compose(&mut buf).unwrap(); + assert_eq!(buf.as_ref(), &[5, b'I', b's', b's', b'u', b'e']); + } + + #[test] + fn caa_flags_display() { + let flags = CaaFlags::default(); + assert_eq!(flags.bits(), 0); + } + + #[test] + fn caa_flags_critical() { + let flags = CaaFlags::critical(); + assert_eq!(flags.bits(), 0x80); + } +} diff --git a/src/rdata/mod.rs b/src/rdata/mod.rs index d4f21148..51c43e8e 100644 --- a/src/rdata/mod.rs +++ b/src/rdata/mod.rs @@ -46,6 +46,7 @@ mod macros; pub mod aaaa; +pub mod caa; pub mod cds; pub mod dname; pub mod dnssec; @@ -102,6 +103,11 @@ rdata_types! { Aaaa, } } + caa::{ + zone { + Caa, + } + } cds::{ zone { Cdnskey,